diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..021da85 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,30 @@ +name: Release and Update Website Changelog + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + test_version: + description: "The fake version to test (e.g., 99.9.9)" + required: true + default: "99.9.9" + +jobs: + release: + # Repo default is read-only; the called workflow needs write, and a reusable + # workflow can never hold more than its caller. + permissions: + contents: write + issues: write + # Pin to a reviewed release tag (or, strongest, a full commit SHA) — not @main. See "Security" below. + uses: sqlitecloud/changelog-action/.github/workflows/action.yml@v1 + with: + name: "SQLite-Memory" + stage_branch: ${{ vars.WEBSITE_STAGE_BRANCH }} # staging branch changelog PRs target + create_release: false + secrets: + personal_access_token: ${{ secrets.WEBSITE_REPO_PAT }} + website_repo: ${{ secrets.WEBSITE_REPO }} + website_changelog_dir: ${{ secrets.WEBSITE_CHANGELOG_DIR }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a860452..5a0d025 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -364,7 +364,7 @@ jobs: echo "::endgroup::" echo "::group::build unittest binary for android" - make build/unittest ${{ matrix.make }} SQLITE_AMALGAM=${SQLITE_DIR}/sqlite3.c + make build/unittest ${{ matrix.make }} SQLITE_AMALGAM=${SQLITE_DIR}/sqlite3.c DEFINES="-DTEST_SQLITE_EXTENSION" echo "::endgroup::" echo "::group::build e2e binary for android" @@ -406,12 +406,12 @@ jobs: - name: unix test sqlite-memory if: matrix.skip_test != true && matrix.os != 'windows-2022' && matrix.name != 'android' - run: ${{ matrix.name == 'linux-musl' && matrix.arch == 'arm64' && 'docker exec alpine' || '' }} make test ${{ matrix.make && matrix.make || ''}} + run: ${{ matrix.name == 'linux-musl' && matrix.arch == 'arm64' && 'docker exec alpine' || '' }} make test ${{ matrix.make && matrix.make || ''}} DEFINES="-DTEST_SQLITE_EXTENSION" - name: windows test sqlite-memory if: matrix.skip_test != true && matrix.name == 'windows' shell: msys2 {0} - run: make test ${{ matrix.make && matrix.make || ''}} + run: make test ${{ matrix.make && matrix.make || ''}} DEFINES="-DTEST_SQLITE_EXTENSION" - name: unix e2e sqlite-memory if: matrix.skip_test != true && matrix.variant != 'local' && matrix.os != 'windows-2022' && matrix.name != 'android' diff --git a/.gitignore b/.gitignore index e7ca7cc..544640e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ test/unittest.dSYM/Contents/Info.plist test/unittest.dSYM/Contents/Resources/DWARF/unittest test/unittest.dSYM/Contents/Resources/Relocations/aarch64/unittest.yml /build +cli/.sqlmem.json +cli/sqlmem diff --git a/API.md b/API.md index e77760f..5e3d5c9 100644 --- a/API.md +++ b/API.md @@ -11,6 +11,7 @@ A SQLite extension that provides semantic memory capabilities with hybrid search - [General Functions](#general-functions) - [Configuration Functions](#configuration-functions) - [Memory Management Functions](#memory-management-functions) + - [Listing Functions](#listing-functions) - [Deletion Functions](#deletion-functions) - [Sync Functions](#sync-functions) - [Virtual Table Module](#virtual-table-module) @@ -34,7 +35,7 @@ sqlite-memory enables semantic search over text content stored in SQLite. It: ## Sync Behavior -All `memory_add_*` functions use **content-hash change detection** to avoid redundant embedding computation. Each piece of content is hashed before processing — if the hash already exists in the database, the content is skipped. +By default, all `memory_add_*` functions use **content-hash change detection** to avoid redundant embedding computation. Each piece of content is hashed before processing — if the hash already exists in the database, the content is skipped. Set `preserve_duplicate_paths=1` to store distinct logical paths even when their content is identical or empty. ### Change Detection @@ -86,12 +87,33 @@ Returns the extension version string. **Parameters:** None -**Returns:** TEXT - Version string (e.g., "0.5.0") +**Returns:** TEXT - Version string (e.g., "1.3.0") **Example:** ```sql SELECT memory_version(); --- Returns: "0.5.0" +-- Returns: "1.3.0" +``` + +--- + +#### `memory_is_enabled()` + +Returns whether the current database has the sqlite-memory schema enabled. + +**Parameters:** None + +**Returns:** INTEGER - 1 if the required sqlite-memory tables are present, 0 otherwise + +**Notes:** +- This checks database state, not whether the extension has been loaded into the current connection +- This is not a schema compatibility check; migrations still use `schema_version` +- `dbmem_vault_fts` is not required because FTS5 support is optional + +**Example:** +```sql +SELECT memory_is_enabled(); +-- Returns: 1 ``` --- @@ -114,8 +136,9 @@ Configures the embedding model to use. - When `provider` is `"local"`, the extension uses the built-in llama.cpp engine and verifies the model file exists - When `provider` is anything other than `"local"`, the extension uses the [vectors.space](https://vectors.space) remote embedding service - Remote embedding requires a free API key from [vectors.space](https://vectors.space) (set via `memory_set_apikey`) -- Settings are persisted in `dbmem_settings` table -- For local models, the embedding engine is initialized immediately +- Provider/model settings are persisted in the `dbmem_settings` table and reused by new connections +- Calling `memory_set_model()` initializes the embedding engine immediately, which can be used to preload and validate the engine +- When provider/model settings are loaded by a new connection, the engine is initialized lazily on first embedding use - **Automatic reindex**: If a model was previously configured and the new provider/model differs, all existing content is automatically re-embedded with the new model. File-based entries are re-read from disk; text-based entries are re-embedded from stored content. Errors on individual entries are silently skipped (best-effort) **Example:** @@ -124,8 +147,8 @@ Configures the embedding model to use. SELECT memory_set_model('local', '/path/to/nomic-embed-text-v1.5.Q8_0.gguf'); -- Remote embedding via vectors.space (requires free API key) -SELECT memory_set_model('openai', 'text-embedding-3-small'); SELECT memory_set_apikey('your-vectorspace-api-key'); +SELECT memory_set_model('openai', 'text-embedding-3-small'); ``` --- @@ -142,7 +165,7 @@ Sets the API key for the [vectors.space](https://vectors.space) remote embedding **Returns:** INTEGER - 1 on success **Notes:** -- API key is stored in memory only, not persisted to disk +- API key is stored in memory only, not persisted to disk, and must be set per connection for remote embeddings - Required when using any provider other than `"local"` - Get a free API key by creating an account at [vectors.space](https://vectors.space) @@ -175,6 +198,9 @@ SELECT memory_set_option('engine_warmup', 1); -- Set minimum score threshold SELECT memory_set_option('min_score', 0.75); + +-- Preserve separate logical paths even when content is identical +SELECT memory_set_option('preserve_duplicate_paths', 1); ``` --- @@ -188,7 +214,7 @@ Retrieves a configuration option value. |-----------|------|-------------| | `key` | TEXT | Option name | -**Returns:** ANY - Option value, or NULL if not set +**Returns:** ANY - Option value, or NULL if not set. `preserve_duplicate_paths` returns `0` by default. **Example:** ```sql @@ -235,19 +261,22 @@ SELECT memory_add_text('Important meeting notes from 2024-01-15...', 'meetings') #### `memory_add_file(path TEXT [, context TEXT])` -Syncs a file to memory. Unchanged files are skipped; modified files are atomically replaced. +Reads a file from disk and syncs it to memory. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `path` | TEXT | Yes | Full path to the file | -| `context` | TEXT | No | Optional context label for grouping memories | +| `path` | TEXT | Yes | File path. Absolute filesystem paths are stored as a portable logical suffix; relative paths are stored as-is | +| `context` | TEXT | No | Optional context label for grouping and sync filters | **Returns:** INTEGER - 1 on success **Notes:** - Only processes files matching configured extensions (default: `md,mdx`) -- File path is stored in `dbmem_content.path` +- File path is stored in `dbmem_content.path` as a portable relative path +- The original local filesystem path is stored in local-only `dbmem_content_source.source_path`, keyed by logical `path` +- For absolute files, the initial logical path is `parent/file` (for example `/Users/me/docs/readme.md` becomes `docs/readme.md`); if that collides, sqlite-memory extends the suffix until it is unique +- If the same logical `path` already exists without local provenance (for example after sync), importing a local file with that logical path updates the existing entry and attaches local provenance instead of creating a duplicate - If the file was previously indexed with different content, the old entry (chunks, embeddings, FTS) is deleted and new content is reindexed — all within a single SAVEPOINT transaction (see [Sync Behavior](#sync-behavior)) - Not available when compiled with `DBMEM_OMIT_IO` @@ -259,6 +288,70 @@ SELECT memory_add_file('/docs/api.md', 'documentation'); --- +#### `memory_add_content(path TEXT, content TEXT [, context TEXT])` + +Indexes caller-provided file content without reading from the filesystem. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `path` | TEXT | Yes | File name or path to store in `dbmem_content.path` | +| `content` | TEXT | Yes | File content to index | +| `context` | TEXT | No | Optional context label for grouping and sync filters | + +**Returns:** INTEGER - 1 on success + +**Notes:** +- The file does not need to exist on disk +- Absolute paths are stored as portable logical suffixes; relative paths are stored as-is +- No row is added to `dbmem_content_source` because content was supplied by the caller rather than read from the local filesystem +- If the path was previously indexed with different content, the old entry (chunks, embeddings, FTS) is deleted and new content is reindexed +- If the new content is already indexed under another path, the stale path is removed and the existing content entry is reused +- Set `preserve_duplicate_paths=1` to preserve separate rows for distinct paths with identical or empty content +- With `preserve_duplicate_paths=1`, an empty `content` value and a trailing slash in `path` creates an explicit empty directory marker, for example `memory_add_content('dirname/', '')` +- Directory markers are stored in `dbmem_content` with a trailing slash path, are shown as directories by `memory_list_files()`, and are not indexed for search +- Directory marker paths cannot contain non-empty content and cannot conflict with a file path of the same name +- With `defer_embeddings=1`, content is stored without computing embeddings or FTS entries (no embedding model required); generate them later with `memory_embed_pending()` +- Available even when compiled with `DBMEM_OMIT_IO` + +**Example:** +```sql +SELECT memory_add_content('docs/api.md', '# API\nContent already loaded by the caller.', 'documentation'); +SELECT memory_set_option('preserve_duplicate_paths', 1); +SELECT memory_add_content('docs/drafts/', ''); +``` + +--- + +#### `memory_rename_file(old_path TEXT, new_path TEXT)` + +Renames an indexed file path in memory without reprocessing content. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `old_path` | TEXT | Yes | Existing logical path stored in `dbmem_content.path`, or exact local `dbmem_content_source.source_path` | +| `new_path` | TEXT | Yes | New path to store in `dbmem_content.path` | + +**Returns:** INTEGER - Number of content entries renamed (0 or 1) + +**Notes:** +- Updates `dbmem_content.path` and keeps any local `dbmem_content_source` metadata attached to the renamed logical path +- It does not rename the file on disk or change the stored `source_path` value +- With `preserve_duplicate_paths=1`, recomputes the path-scoped `hash` and updates related embedding and FTS rows +- With `preserve_duplicate_paths=1`, rename requires saved content; if `save_content=0` was used for the row, `memory_rename_file()` returns an error because the path-scoped hash cannot be recomputed safely +- Does not change `value` or stored embedding/FTS content +- Fails if `new_path` already exists because `dbmem_content.path` is unique +- Explicit directory markers can be renamed only to another trailing-slash marker path; this renames only the marker row, not child paths +- Fails if `old_path` matches more than one row across `path` and local `dbmem_content_source.source_path`; pass a unique logical path or exact local source path + +**Example:** +```sql +SELECT memory_rename_file('/docs/old.md', '/docs/new.md'); +``` + +--- + #### `memory_add_directory(path TEXT [, context TEXT])` Synchronizes a directory with memory. Adds new files, reindexes modified files, and removes entries for deleted files. @@ -269,10 +362,12 @@ Synchronizes a directory with memory. Adds new files, reindexes modified files, | `path` | TEXT | Yes | Full path to the directory | | `context` | TEXT | No | Optional context label applied to all files | -**Returns:** INTEGER - Number of new files processed +**Returns:** INTEGER - Number of files scanned successfully **Notes:** - Recursively scans subdirectories +- File paths are stored relative to the directory path passed to `memory_add_directory` +- Each filesystem-backed row stores the original local file path in local-only `dbmem_content_source.source_path` - Only processes files matching configured extensions - **Phase 1 — Cleanup**: Removes entries for files that no longer exist on disk - **Phase 2 — Scan**: Processes all matching files: @@ -286,27 +381,79 @@ Synchronizes a directory with memory. Adds new files, reindexes modified files, **Example:** ```sql SELECT memory_add_directory('/path/to/docs'); --- Returns: 42 (number of new files processed) +-- Returns: 42 (number of files scanned successfully) SELECT memory_add_directory('/project/notes', 'project-notes'); -- Safe to call again — unchanged files are skipped SELECT memory_add_directory('/path/to/docs'); --- Returns: 0 (nothing changed) +-- Returns the number of files scanned; unchanged files are skipped internally +``` + +--- + +#### `memory_materialize_files([root_path TEXT])` + +Writes all stored file contents from `dbmem_content` back to the filesystem. + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `root_path` | TEXT | No | Filesystem root used to materialize relative paths | + +**Returns:** INTEGER - Number of files or explicit directory markers processed + +**Notes:** +- Creates parent directories as needed +- Explicit directory markers created with `memory_add_content('dirname/', '')` are materialized as directories +- Relative paths are written under `root_path` when provided +- Paths containing `..` segments are rejected to prevent writing outside the materialization root +- If a file already exists with the same content, it is left unchanged and no error is returned +- If a file exists with different content, it is overwritten with `dbmem_content.value` +- Rows with `NULL` content cannot be materialized +- Not available when compiled with `DBMEM_OMIT_IO` + +**Example:** +```sql +SELECT memory_materialize_files('/path/to/project'); +``` + +--- + +### Listing Functions + +#### `memory_list_files()` + +Returns a JSON tree with the indexed directories and files stored in `dbmem_content.path`. + +**Returns:** TEXT - JSON object with a `root` string and a hierarchical `children` array + +**Notes:** +- Rows added with `memory_add_text()` use generated paths and can appear as root-level file nodes +- Legacy absolute paths are displayed with their common directory prefix removed when possible +- Directory nodes are derived from indexed file paths and explicit directory markers +- Path separators are normalized to `/` in the returned JSON +- Sibling nodes are sorted with directories first, then files; each group is alphabetical +- File nodes include an `indexed` boolean: `false` while content is waiting for embedding generation (see `defer_embeddings` and `memory_embed_pending()`), `true` otherwise + +**Example:** +```sql +SELECT memory_list_files(); +-- {"root":"","children":[{"type":"directory","name":"docs","path":"docs","children":[{"type":"file","name":"readme.md","path":"docs/readme.md","indexed":true}]}]} ``` --- ### Deletion Functions -#### `memory_delete(hash INTEGER)` +#### `memory_delete(hash TEXT)` Deletes a specific memory by its hash. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| -| `hash` | INTEGER | The hash identifier of the memory to delete | +| `hash` | TEXT | The 16-character hexadecimal hash identifier of the memory to delete | **Returns:** INTEGER - Number of content entries deleted (0 or 1) @@ -321,7 +468,7 @@ Deletes a specific memory by its hash. SELECT hash FROM dbmem_content WHERE path LIKE '%readme%'; -- Delete by hash -SELECT memory_delete(1234567890); +SELECT memory_delete('9e3779b97f4a7c15'); ``` --- @@ -350,6 +497,32 @@ SELECT memory_delete_context('meetings'); --- +#### `memory_delete_file(path TEXT)` + +Deletes an indexed file or explicit directory marker by its stored path. + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `path` | TEXT | Exact logical path stored in `dbmem_content.path`, or exact local `dbmem_content_source.source_path` | + +**Returns:** INTEGER - Number of content entries deleted (0 or 1) + +**Notes:** +- Atomically deletes the matching `dbmem_content` entry and its rows in `dbmem_vault` and `dbmem_vault_fts` +- Does not delete or modify the file on disk +- When the path names an explicit directory marker, deletes only that marker row; files under the directory are not deleted +- Directory markers can be matched with either `dirname` or `dirname/` +- Path matching is exact; if a row has local source metadata, `dbmem_content_source.source_path` is also accepted +- Fails if the argument matches more than one row across `path` and local `dbmem_content_source.source_path` + +**Example:** +```sql +SELECT memory_delete_file('/docs/readme.md'); +``` + +--- + #### `memory_clear()` Deletes all memories from the database. @@ -359,7 +532,7 @@ Deletes all memories from the database. **Returns:** INTEGER - 1 on success **Notes:** -- Clears `dbmem_content`, `dbmem_vault`, and `dbmem_vault_fts` +- Clears `dbmem_content`, local `dbmem_content_source`, `dbmem_vault`, and `dbmem_vault_fts` - Does not delete settings from `dbmem_settings` - Does not clear the embedding cache (`dbmem_cache`) - Uses SAVEPOINT transaction for atomicity @@ -419,6 +592,8 @@ Enables CRDT-based synchronization for `dbmem_content` via sqlite-sync. Uses the - With arguments, sets a row-level filter: only the specified contexts are replicated - Block-level LWW on `value` enables line-level conflict resolution for text content - All other columns use the default CLS algorithm +- `source_path` is not part of `dbmem_content`; it is stored in local-only `dbmem_content_source` and is not synchronized +- After sync merges remote changes into `dbmem_content.value`, `dbmem_content.hash` can be stale and local embeddings in `dbmem_vault` can still point to the previous content. Run `memory_reindex()` to recompute changed hashes and generate or refresh local embeddings **Example:** ```sql @@ -427,6 +602,9 @@ SELECT memory_enable_sync(); -- Sync only specific contexts SELECT memory_enable_sync('conversation', 'project-docs'); + +-- After receiving synced content from other clients +SELECT memory_reindex(); ``` --- @@ -450,6 +628,82 @@ SELECT memory_disable_sync(); --- +#### `memory_reindex()` + +Generates or refreshes local embeddings for stored content. + +**Parameters:** None + +**Returns:** INTEGER - Number of content rows reindexed or realigned + +**Notes:** +- Requires an embedding model configured with `memory_set_model()` or loaded from persisted provider/model settings +- Processes rows in `dbmem_content` that have stored `value` +- Skips rows whose `dbmem_content.hash` already matches `value` and whose local `dbmem_vault` entries already exist +- After sync merges remote changes into `dbmem_content.value`, recomputes stale hashes, refreshes missing embeddings, and removes old local index rows +- Useful after receiving synced content because `dbmem_vault`, `dbmem_vault_fts`, and `dbmem_content_source` are local-only and are not synchronized + +**Example:** +```sql +SELECT memory_reindex(); +``` + +--- + +#### `memory_embed_pending([limit INTEGER])` + +Generates embeddings and FTS entries for content stored without them (see the `defer_embeddings` option). + +**Parameters:** +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `limit` | INTEGER | No | Maximum number of pending content rows to process in this call (must be positive). When omitted, all pending rows are processed | + +**Returns:** INTEGER - Number of pending content rows processed + +**Notes:** +- Requires an embedding model configured with `memory_set_model()` or loaded from persisted provider/model settings +- A content row is pending when it has a non-empty stored `value` and no `dbmem_vault` entries +- Each row is processed in its own SAVEPOINT transaction, so a row is either fully indexed or untouched; a failed or interrupted call can simply be retried and other connections can observe per-file progress while a batch is running +- Content whose parsing produces no chunks (e.g. whitespace-only text) is marked as processed so it is not retried +- Designed for background workers: call in a loop with a small `limit` and poll `memory_pending_count()` to report progress +- Returns 0 when nothing is pending + +**Example:** +```sql +-- store content instantly, without embeddings +SELECT memory_set_option('defer_embeddings', 1); +SELECT memory_add_content('docs/api.md', '# API\nUploaded from the dashboard.'); + +-- later, from a background process: embed in batches of 10 +SELECT memory_embed_pending(10); + +-- or process the whole backlog in one call +SELECT memory_embed_pending(); +``` + +--- + +#### `memory_pending_count()` + +Returns the number of content rows waiting for embedding generation. + +**Parameters:** None + +**Returns:** INTEGER - Number of pending content rows + +**Notes:** +- Counts rows with a non-empty stored `value` and no `dbmem_vault` entries +- Useful for progress reporting: `1 - pending/total` while a `memory_embed_pending()` loop is running +- Empty files and directory markers are never counted as pending + +**Example:** +```sql +SELECT memory_pending_count(); +``` + +--- + ### `memory_search` A virtual table for performing hybrid semantic search. @@ -469,10 +723,10 @@ SELECT * FROM memory_search WHERE query = 'search text'; **Output columns:** | Column | Type | Description | |--------|------|-------------| -| `hash` | INTEGER | Content hash identifier | +| `hash` | TEXT | 16-character hexadecimal content hash | | `seq` | INTEGER | Chunk sequence number within the document (0-based) | | `ranking` | REAL | Combined similarity score (0.0 - 1.0) | -| `path` | TEXT | Source file path or generated UUID for text content | +| `path` | TEXT | Portable logical file path or generated UUID for text content | | `snippet` | TEXT | Text snippet from the matching chunk | **Notes:** @@ -528,7 +782,7 @@ int sqlite3_memory_register_provider( ); ``` -Registers a custom embedding engine for a specific database connection. Once registered, calling `memory_set_model(provider_name, model)` from SQL will use your engine instead of the built-in local or remote engines. +Registers a custom embedding engine for a specific database connection. Once registered, calling `memory_set_model(provider_name, model)` from SQL will use your engine instead of the built-in local or remote engines. If provider/model settings were already loaded from `dbmem_settings`, the custom engine is initialized lazily on first embedding use after registration. **Parameters:** | Parameter | Type | Description | @@ -542,7 +796,8 @@ Registers a custom embedding engine for a specific database connection. Once reg **`dbmem_provider_t` struct:** ```c typedef struct { - // Called when memory_set_model(provider_name, model) is executed. + // Called when memory_set_model(provider_name, model) is executed, or lazily + // on first embedding use when provider/model were loaded from settings. // api_key is the value set via memory_set_apikey() (may be NULL). // xdata is the user pointer from this struct. // Return an opaque engine pointer on success, or NULL on error (fill err_msg). @@ -564,8 +819,8 @@ typedef struct { **`dbmem_embedding_result_t` struct:** ```c typedef struct { - int n_tokens; // Number of tokens processed - int n_tokens_truncated; // Tokens that were truncated (0 if none) + int n_tokens; // Number of processed tokens (0 if unknown) + bool truncated; // True when the input was truncated before embedding int n_embd; // Embedding dimension float *embedding; // Embedding vector (engine-owned, valid until next call or free) } dbmem_embedding_result_t; @@ -574,6 +829,7 @@ typedef struct { **Notes:** - Works regardless of `DBMEM_OMIT_LOCAL_ENGINE` / `DBMEM_OMIT_REMOTE_ENGINE` compile flags - The `embedding` buffer in `dbmem_embedding_result_t` must remain valid until the next `compute` call or `free` — it is engine-owned, not copied by the caller +- `n_tokens` is metadata about the processed input when the engine can provide it; `truncated` is a boolean flag, not a truncated-token count - Only one custom provider can be registered per connection at a time; registering again replaces the previous one - The provider struct is copied by value; the caller does not need to keep it alive after registration @@ -596,7 +852,7 @@ static int my_compute(void *engine, const char *text, int text_len, void *xdata, // ... fill vec with your embedding ... result->n_embd = e->dimension; result->n_tokens = text_len / 4; - result->n_tokens_truncated = 0; + result->truncated = false; result->embedding = vec; return 0; } @@ -631,7 +887,7 @@ sqlite3_memory_register_provider(db, "my-engine", &provider); | `max_tokens` | INTEGER | 400 | Maximum tokens per chunk | | `overlay_tokens` | INTEGER | 80 | Token overlap between consecutive chunks | | `chars_per_tokens` | INTEGER | 4 | Estimated characters per token | -| `save_content` | INTEGER | 1 | Store original content (1=yes, 0=no) | +| `save_content` | INTEGER | 1 | Store original content (1=yes, 0=no). Required for renaming rows created with `preserve_duplicate_paths=1` | | `skip_semantic` | INTEGER | 0 | Skip markdown parsing, treat as raw text | | `skip_html` | INTEGER | 1 | Strip HTML tags when parsing | | `extensions` | TEXT | "md,mdx" | Comma-separated file extensions to process | @@ -645,6 +901,8 @@ sqlite3_memory_register_provider(db, "my-engine", &provider); | `embedding_cache` | INTEGER | 1 | Cache embeddings to avoid redundant computation | | `cache_max_entries` | INTEGER | 0 | Max cache entries (0 = no limit). When exceeded, oldest entries are evicted | | `search_oversample` | INTEGER | 0 | Search oversampling multiplier (0 = no oversampling). When set, retrieves N * multiplier candidates from each index before merging down to N final results | +| `preserve_duplicate_paths` | INTEGER | 0 | Preserve distinct logical paths for identical or empty content. When enabled, `dbmem_content.hash` is path-scoped and identifies an entry rather than only the raw content | +| `defer_embeddings` | INTEGER | 0 | Store content without computing embeddings or FTS entries. Deferred content is invisible to search until processed with `memory_embed_pending()` or `memory_reindex()`. Requires `save_content=1` | --- @@ -654,7 +912,7 @@ The extension tracks two timestamps for each memory: ### `created_at` -- Set automatically when content is added via `memory_add_text`, `memory_add_file`, or `memory_add_directory` +- Set automatically when content is added via `memory_add_text`, `memory_add_file`, `memory_add_content`, or `memory_add_directory` - Stored as Unix timestamp (seconds since 1970-01-01 00:00:00 UTC) - Never updated after initial creation @@ -769,6 +1027,21 @@ FROM dbmem_content WHERE last_accessed > 0 ORDER BY last_accessed DESC LIMIT 10; + +-- Tokens consumed and truncation per context +-- (n_tokens / truncated were added in schema version 2; dbmem_content_source was added in schema version 3; source_path moved out of dbmem_content and became path-keyed in schema version 4) +SELECT + COALESCE(c.context, '(none)') as context, + SUM(v.n_tokens) as tokens_processed, + SUM(v.truncated) as truncated_chunks +FROM dbmem_vault v +JOIN dbmem_content c ON c.hash = v.hash +GROUP BY c.context; + +-- Chunks that the embedding model truncated on input +SELECT hash, seq, length, n_tokens +FROM dbmem_vault +WHERE truncated = 1; ``` --- @@ -777,7 +1050,7 @@ LIMIT 10; | Option | Description | |--------|-------------| -| `DBMEM_OMIT_IO` | Omit file/directory functions (for WASM) | +| `DBMEM_OMIT_IO` | Omit filesystem-backed functions: `memory_add_file`, `memory_add_directory`, and `memory_materialize_files` (for WASM) | | `DBMEM_OMIT_LOCAL_ENGINE` | Omit llama.cpp local engine (for remote-only builds) | | `DBMEM_OMIT_REMOTE_ENGINE` | Omit vectors.space remote engine (for local-only builds) | | `SQLITE_CORE` | Compile as part of SQLite core (not as loadable extension) | @@ -797,5 +1070,5 @@ Errors can be caught using standard SQLite error handling mechanisms. ```sql -- Example error handling in application code SELECT memory_add_text(123); -- Error: expects TEXT parameter -SELECT memory_delete('abc'); -- Error: expects INTEGER parameter +SELECT memory_delete(123); -- Error: expects TEXT parameter ``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d35fd51 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,204 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.3.5] - 2026-06-10 + +### Added + +- **Deferred embedding generation** via the new `defer_embeddings` option. When enabled, content is stored immediately without computing embeddings or FTS entries, so ingestion needs no embedding model and returns instantly. Deferred content is invisible to `memory_search` until it is processed. Requires `save_content=1`. +- **`memory_embed_pending([limit])`** generates the embeddings and FTS entries for deferred content, either all at once or in batches of `limit` rows. Each row is processed in its own SAVEPOINT, so an interrupted call can simply be retried and other connections observe per-file progress while a batch runs. +- **`memory_pending_count()`** returns how many rows are still waiting for embedding generation, for progress reporting during a `memory_embed_pending()` loop. + +### Changed + +- File nodes returned by `memory_list_files()` now include an `indexed` boolean, which is `false` while content is waiting for embedding generation. + +## [1.3.4] - 2026-06-09 + +### Fixed + +- **`memory_rename_file()` with `preserve_duplicate_paths=1`** now recomputes the path-scoped hash and updates the related embedding and FTS rows in a single transaction. Previously a renamed entry kept its old hash and its embeddings and FTS rows were left orphaned, so the renamed content stopped matching correctly in search. + +### Changed + +- Renaming a `preserve_duplicate_paths` row that was created with `save_content=0` now returns a clear error instead of silently corrupting the entry, because the path-scoped hash cannot be recomputed without the stored content. + +## [1.3.3] - 2026-06-05 + +### Added + +- **Explicit empty directory markers.** With `preserve_duplicate_paths=1`, `memory_add_content('dirname/', '')` records a directory that contains no files. Markers appear as directories in `memory_list_files()`, are recreated as real directories by `memory_materialize_files()`, are excluded from search, and can be deleted with `memory_delete_file()` using either `dirname` or `dirname/` (which removes only the marker, never its children). + +### Changed + +- **Provider and model settings are now picked up automatically by new connections.** The embedding engine is initialized lazily on first use from the settings persisted in `dbmem_settings`, so `memory_set_model()` no longer has to be called on every connection. Calling it explicitly still initializes the engine immediately, which is useful to preload and validate a model. API keys remain connection-scoped: `memory_set_apikey()` must still be called per connection for remote providers. +- `memory_materialize_files()` now counts directory markers in its return value. + +### Fixed + +- Re-adding the same path with `preserve_duplicate_paths=1` is now idempotent instead of churning rows on every call. + +## [1.3.2] - 2026-06-04 + +### Added + +- **`preserve_duplicate_paths` option** keeps separate entries for distinct logical paths even when their content is identical or empty. With it enabled, `dbmem_content.hash` becomes path-scoped and identifies an entry rather than only its raw content. Intended for virtual-file and editor-style workflows where two files may legitimately hold the same text. + +## [1.3.1] - 2026-06-04 + +### Fixed + +- Error messages returned by many `memory_*` functions were truncated or garbled because of a wrong length argument. Errors now arrive intact and readable. + +## [1.3.0] - 2026-05-28 + +### Added + +- **`memory_add_content(path, content [, context])`** indexes content supplied by the caller without reading the filesystem, so applications that already hold the text (uploads, editors, generated documents) can index it directly. Available even in `DBMEM_OMIT_IO` builds. +- **`memory_rename_file(old_path, new_path)`** renames an indexed path without re-reading or re-embedding its content. +- **`memory_delete_file(path)`** deletes a single indexed entry by logical path or by exact local source path. +- **`memory_list_files()`** returns the indexed content as a JSON directory and file tree. +- **`memory_materialize_files([root_path])`** writes stored content back to disk, creating parent directories as needed. Paths containing `..` are rejected, and files that already match are left untouched. +- **`memory_is_enabled()`** reports whether the current database already has the sqlite-memory schema. + +### Changed + +- **File paths are now portable.** Absolute paths are stored as a logical suffix (`/Users/me/docs/readme.md` becomes `docs/readme.md`) and `memory_add_directory()` stores paths relative to the scanned root, so an indexed database can be synced between machines. The original local path moves to `dbmem_content_source`, a local-only table that is never synchronized. +- **Local path collisions are resolved automatically.** When a logical suffix would collide, it is extended until unique. Importing a local file whose logical path already exists without local provenance — for example after a sync — updates that entry and attaches provenance to it instead of creating a duplicate. +- `memory_add_directory()` now returns the number of files scanned successfully, rather than only the number of newly processed files. +- `memory_clear()` also clears the local `dbmem_content_source` table. + +## [1.2.2] - 2026-05-22 + +### Fixed + +- **Adding content before configuring a model no longer crashes.** `memory_add_text()`, `memory_add_file()` and `memory_add_directory()` now return "memory_set_model must be called before adding content". +- Searching an empty database now says to add content first, instead of reporting the misleading "embedding dimension is not specified". + +## [1.2.1] - 2026-05-21 + +### Fixed + +- **Local embedding crashes on certain content.** Whitespace-only chunks are no longer sent to the encoder, and the llama.cpp context is sized from the configured chunk window with batch limits kept in step, which removes encoder assertion failures during indexing. +- Changing `max_tokens`, `overlay_tokens` or `chars_per_tokens` now rebuilds the local engine and invalidates the cached local embeddings, so stale embeddings computed with the old token window are no longer reused. +- Local engine error messages are now per-thread, so errors from one connection are no longer reported on another. + +## [1.2.0] - 2026-05-12 + +### Added + +- **Token usage and truncation are now recorded** for every embedding, in the new `n_tokens` and `truncated` columns of `dbmem_vault` and `dbmem_cache`. Existing databases are migrated automatically on open. Cached embeddings restore this metadata instead of reporting zeros, making it possible to see which content was truncated by the model. + +### Changed + +- **C API (breaking):** in `dbmem_embedding_result_t`, the `n_tokens_truncated` integer is replaced by a `truncated` boolean, and `n_tokens` now means processed tokens (`0` when unknown). Custom providers registered with `sqlite3_memory_register_provider` must be updated. + +## [1.1.0] - 2026-05-06 + +### Added + +- **`sqlmem` command line tool** for managing sqlite-memory projects from the terminal. It creates and manages the database, downloads and loads the required extensions, configures the embedding model, indexes Markdown sources, runs searches, and can watch files for changes. `sqlmem mcp` exposes the memory tools to agents over MCP (stdio or HTTP), and optional PDF indexing can be enabled with `sqlmem config set pdf.enabled true`. See [`cli/README.md`](cli/README.md). +- **MDX support.** Files with an `.mdx` extension now have their `import`/`export` statements and `{...}` JSX expressions stripped before indexing, so MDX documents are indexed as clean prose instead of polluting search results with JavaScript scaffolding. + +### Fixed + +- Remote providers that omit `output_dimension` in their response no longer fail with "Missing embedding data in API response"; the dimension is inferred from the returned embedding. + +## [1.0.0] - 2026-04-22 + +### Changed + +- **Content hashes are now TEXT** (a 16-character hex string such as `'9e3779b97f4a7c15'`) across `dbmem_content`, `dbmem_vault` and `dbmem_cache`, and the `hash` column of `memory_search` returns that value. `memory_delete()` now takes the hash as TEXT. **Databases created with earlier versions must be rebuilt.** +- **`memory_set_model()` is now atomic.** The engine switch, settings update and reindex all happen in one transaction; if any step fails the previous provider, model and engine are restored, instead of leaving the connection half-configured. +- **`memory_set_apikey()` now takes effect immediately** on an already-initialized remote engine, so it no longer has to be called before `memory_set_model()`. +- Switching between provider classes (custom, local, remote) frees the previous engine right away instead of keeping it resident until the connection closes, which matters for large local models holding RAM or VRAM. +- `memory_set_option()` now accepts `0` for `max_results`, `vector_weight`, `text_weight` and `min_score`; previously zero was silently ignored and the old value kept. +- `memory_add_directory()` runs its cleanup pass transactionally and then processes each file in its own transaction, so one failing file rolls back only itself and previously indexed files remain valid. + +### Fixed + +- **`memory_add_directory()` could delete entries from unrelated directories** sharing a name prefix — syncing `/docs` could remove entries belonging to `/docs-old`. Paths are now matched on real directory boundaries. +- **Markdown headings did not start a new chunk**, so sections were merged into the preceding one and chunk boundaries did not follow the document structure as documented. +- `memory_reindex()` is now transactional, aborts on the first failing row instead of silently swallowing errors, and no longer fails when a leftover temporary table exists. +- Repeated `memory_search` queries on the same statement could reuse stale results; the cursor is now fully reset between queries. +- Fixed a potential crash when the local embedding engine reported a tokenize or encode failure. + +## [0.9.0] - 2026-04-13 + +### Added + +- **Memory synchronization between agents.** `memory_enable_sync([context, ...])` enables CRDT-based sync of `dbmem_content` through [sqlite-sync](https://github.com/sqliteai/sqlite-sync), using block-level LWW on the content so concurrent line-level edits from different agents merge without conflicts. Called with no arguments it syncs everything; with one or more context names it replicates only those contexts. `memory_disable_sync()` removes the sync infrastructure while preserving the data. +- **`memory_reindex()`** generates embeddings and FTS entries for content that has none — the situation after pulling content from other agents, since embeddings are always local and never synchronized. It also repairs hashes left stale by a CRDT merge. + +### Fixed + +- **Context-filtered searches returned wrong or empty results.** `memory_search` with `context = ...` referenced a nonexistent column in the vector branch ([#2](https://github.com/sqliteai/sqlite-memory/issues/2)). +- Combining `query` and `context` without `max_entries` assigned the values to the wrong search parameters. +- A large `max_results` combined with `search_oversample` could overflow and under-allocate; the query now fails cleanly instead. + +## [0.8.5] - 2026-04-07 + +### Fixed + +- macOS builds now resolve SQLite symbols from the host process at load time, so the extension loads correctly into arbitrary SQLite builds and alongside other extensions. +- Extension loading is now failure-safe: if any function fails to register, the extension cleans up instead of leaking. +- Hardened the remote embedding engine against over-long provider and model names and against invalid or empty embedding arrays in the API response. +- `memory_add_*` now reports oversized content as an error instead of silently truncating it, and error messages from the file, directory and search paths are correctly bounded and formatted. + +### Changed + +- `API.md` now documents `memory_search` correctly: `query`, `max_entries` and `context` are hidden filter columns used in `WHERE` (`context` was previously listed as an output column), and the output columns are `hash`, `seq`, `ranking`, `path` and `snippet`. It also gained a C API section for `sqlite3_memory_register_provider`. + +## [0.8.3] - 2026-04-01 + +### Fixed + +- The macOS slice of the shipped `memory.xcframework` now uses the versioned bundle layout required by Xcode 26, fixing framework validation and embedding failures for Apple developers. The xcframework release asset is also published correctly again. + +## [0.8.2] - 2026-03-24 + +### Fixed + +- Renamed internal exported symbols to a `dbmemory_*` prefix, resolving the symbol conflicts that prevented sqlite-memory from being built or statically linked alongside sqlite-sync. + +## [0.8.1] - 2026-03-17 + +### Changed + +- **C API (breaking):** `dbmem_provider_t` gained an `xdata` user pointer, and all three provider callbacks now receive it. Providers written against 0.8.0 must be updated. + +## [0.8.0] - 2026-03-17 + +### Added + +- **Custom embedding providers.** `sqlite3_memory_register_provider()` lets a host application plug in its own embedding engine, which is then selected from SQL with `memory_set_model('', '')`. Custom providers work regardless of the `DBMEM_OMIT_LOCAL_ENGINE` and `DBMEM_OMIT_REMOTE_ENGINE` build options, and are used for both indexing and search. + +## [0.7.5] - 2026-03-17 + +### Added + +- **`OMIT_CURL=1` build option.** On Apple platforms the remote embedding engine is built on `NSURLSession` instead of bundling libcurl and mbedTLS, producing a considerably smaller binary with no third-party TLS dependency. Remote embeddings continue to work unchanged. + +## [0.7.1] - 2026-03-04 + +### Added + +- sqlite-memory is now bundled into `sqlite-wasm` releases. + +## [0.7.0] - 2026-03-04 + +Initial public release. + +### Added + +- **Hybrid semantic search** through the `memory_search` virtual table, combining vector similarity with FTS5 full-text search. Results expose `hash`, `seq`, `ranking`, `path` and `snippet`, and queries can be filtered by `context` or capped per query with `max_entries`. +- **Markdown-aware indexing** with `memory_add_text()`, `memory_add_file()` and `memory_add_directory()`. Content is chunked along semantic boundaries, and content-hash change detection means unchanged files are skipped, modified files are atomically replaced, and deleted files are cleaned up — so sync functions are cheap to call repeatedly. +- **Local and remote embeddings**, selected with `memory_set_model()`: the built-in llama.cpp engine for local GGUF models, or the [vectors.space](https://vectors.space) service for remote providers (with `memory_set_apikey()`). Changing the provider or model automatically re-embeds existing content. +- **Embedding cache** so re-indexing the same text skips redundant computation and API calls, with optional size-based eviction (`embedding_cache`, `cache_max_entries`) and `memory_cache_clear()`. +- **Deletion functions**: `memory_delete()`, `memory_delete_context()` and `memory_clear()`. +- **Configuration** via `memory_set_option()` and `memory_get_option()`, covering chunking (`max_tokens`, `overlay_tokens`), parsing (`skip_semantic`, `skip_html`, `extensions`), search behaviour (`max_results`, `fts_enabled`, `vector_weight`, `text_weight`, `min_score`, `search_oversample`) and storage (`save_content`, `update_access`). +- **Transactional safety**: every ingest runs inside a SAVEPOINT, so content is never left partially indexed. +- **Prebuilt binaries** for macOS, iOS and iOS Simulator (XCFramework), Linux (including musl), Windows, Android and WASM, plus the `DBMEM_OMIT_IO`, `DBMEM_OMIT_LOCAL_ENGINE` and `DBMEM_OMIT_REMOTE_ENGINE` build options for trimming the extension. diff --git a/Makefile b/Makefile index c3fb299..1e5ad22 100644 --- a/Makefile +++ b/Makefile @@ -561,7 +561,7 @@ ifeq ($(PLATFORM),windows) else unzip -o $(CURL_ZIP) -d $(CURL_DIR)/src/. endif - cd $(CURL_SRC) && ./configure \ + cd $(CURL_SRC) && env -u LDFLAGS -u CPPFLAGS -u CFLAGS -u LIBS ./configure \ --without-libpsl \ --disable-alt-svc \ --disable-ares \ diff --git a/README.md b/README.md index 6dc07d8..c4c5d8c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ +
+ + SQLite AI + + +

SQLite-Memory

+

Persistent, searchable memory for AI agents.
+ Markdown-based memory with semantic search, hybrid retrieval, and offline-first sync between agents. Drop-in memory layer for any LLM workflow.

+ +

+ Free managed instance → · + Docs · + Website · + Blog +

+ +

+ Data: + Vector · + Sync · + Columnar · + JS +
+ AI: + AI · + Agent · + Memory · + MCP +
+

+
+ +
+ +> **Multiple agents need shared memory?** SQLite-Memory syncs locally via CRDTs; pair it with **[SQLite Cloud](https://dashboard.sqlitecloud.io/auth/sign-in)** (or your own Postgres/Supabase) to coordinate memory across machines, users, and workers. Free tier available. + +--- + # SQLite Memory A SQLite extension that gives AI agents persistent, searchable memory, optimized for markdown content. Features hybrid semantic search (vector similarity + FTS5), markdown-aware chunking, and local embedding via llama.cpp. @@ -90,6 +128,10 @@ SELECT memory_set_model('local', '/path/to/nomic-embed-text-v1.5.Q8_0.gguf'); -- SELECT memory_set_apikey('your-vectorspace-api-key'); -- SELECT memory_set_model('openai', 'text-embedding-3-small'); +-- Provider/model settings are persisted. New connections reuse them and +-- initialize the engine lazily on first embedding use. Remote API keys are +-- connection-scoped, so call memory_set_apikey() on each remote connection. + -- Add some knowledge SELECT memory_add_text('SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine. SQLite is the @@ -101,6 +143,7 @@ systems, and AI applications.', 'concepts'); -- Add an entire documentation directory SELECT memory_add_directory('/path/to/docs', 'project-docs'); +-- Paths are stored relative to /path/to/docs, so the database can be materialized elsewhere. -- Search your memory semantically SELECT path, snippet, ranking @@ -116,6 +159,22 @@ WHERE query = 'how do databases store information efficiently'; -- └──────────────┴─────────────────────────────────────┴─────────┘ ``` +### Command Line: sqlmem + +[`sqlmem`](cli/README.md) is the Go CLI for managing SQLite Memory projects from the terminal. It creates `.sqlmem.json`, manages the SQLite database, downloads and loads the SQLite extensions, configures embedding models, indexes Markdown sources, runs hybrid searches, watches files for changes, and exposes the memory tools over MCP. + +Use it when you want a project-level workflow around sqlite-memory without writing SQL directly: + +```bash +cd cli +make build +./sqlmem init --model /path/to/embedding-model.gguf +./sqlmem add ../docs +./sqlmem search -q "how do I configure memory?" +``` + +See the [`sqlmem` README](cli/README.md) for installation, configuration, extension cache paths, PDF support, MCP, and command examples. + ### Example: Building an AI Agent with Memory ```python @@ -127,7 +186,8 @@ conn.enable_load_extension(True) conn.load_extension('./vector') conn.load_extension('./memory') -# One-time setup +# One-time setup. Later connections reuse the saved provider/model and lazily +# load the engine on first embedding use. conn.execute("SELECT memory_set_model('local', './models/nomic-embed-text-v1.5.Q8_0.gguf')") # Store conversation context @@ -155,18 +215,55 @@ memories = recall("what's the project timeline") ## Intelligent Sync -All `memory_add_*` functions use content-hash change detection to avoid redundant work: +By default, all `memory_add_*` functions use content-hash change detection to avoid redundant work: - **`memory_add_text`**: Computes a hash of the content. If the same content was already indexed, it is skipped entirely. No duplicate embeddings are ever created. -- **`memory_add_file`**: Reads the file and hashes its content. If the file was previously indexed with different content, the old entry (chunks, embeddings, FTS) is atomically replaced. Unchanged files are skipped. +- **`memory_add_file`**: Reads the file and hashes its content. If the file was previously indexed with different content, the old entry (chunks, embeddings, FTS) is atomically replaced. Unchanged files are skipped. Absolute file paths are stored as portable logical suffixes, while the original local path is retained only in local metadata. +- **`memory_add_content`**: Indexes caller-provided file content without reading from the filesystem, preserving the supplied logical file name/path and optional context. - **`memory_add_directory`**: Performs a full two-phase sync: 1. **Cleanup**: Removes database entries for files that no longer exist on disk - 2. **Scan**: Recursively processes all matching files - adding new ones, replacing modified ones, and skipping unchanged ones + 2. **Scan**: Recursively processes all matching files - adding new ones, replacing modified ones, and skipping unchanged ones. Stored paths are relative to the scanned directory root, with local provenance retained only in local metadata. + +For virtual-file or editor workflows that need separate logical paths even when content is identical or empty, enable path-preserving storage: -`memory_add_text()` and `memory_add_file()` each run inside a SQLite SAVEPOINT transaction. `memory_add_directory()` performs its cleanup pass transactionally and then processes each file in its own transaction. If one file fails, that file rolls back cleanly and previously-committed files remain valid; there are no partially-indexed rows or orphaned chunk/FTS entries for the failed file. +```sql +SELECT memory_set_option('preserve_duplicate_paths', 1); +``` + +In this mode, `dbmem_content.hash` identifies the stored entry and is scoped by path. + +The same mode supports explicit empty directory markers for virtual filesystems: + +```sql +SELECT memory_set_option('preserve_duplicate_paths', 1); +SELECT memory_add_content('dirname/', ''); +``` + +Directory markers are listed as directories, materialized as directories by `memory_materialize_files()`, and ignored by `memory_search`. + +`memory_add_text()`, `memory_add_file()`, and `memory_add_content()` each run inside a SQLite SAVEPOINT transaction. `memory_add_directory()` performs its cleanup pass transactionally and then processes each file in its own transaction. If one file fails, that file rolls back cleanly and previously-committed files remain valid; there are no partially-indexed rows or orphaned chunk/FTS entries for the failed file. This makes all sync functions safe to call repeatedly - for example, on a cron schedule or at agent startup - with minimal overhead. +## Deferred Embeddings + +For interactive workflows (e.g. a dashboard upload) where content should appear immediately and embeddings can be computed later by a background process, enable deferred mode: + +```sql +-- store content instantly: no embedding model needed, nothing is computed +SELECT memory_set_option('defer_embeddings', 1); +SELECT memory_add_content('docs/api.md', '# API\nUploaded from the dashboard.'); + +-- pending files are visible right away ("indexed":false in the JSON tree) +SELECT memory_list_files(); + +-- later, from a background worker: embed in batches and report progress +SELECT memory_embed_pending(10); -- returns rows processed in this batch +SELECT memory_pending_count(); -- rows still waiting +``` + +Deferred content is stored in `dbmem_content` but is invisible to `memory_search` until it is embedded. Each file is embedded in its own transaction, so a file is either fully indexed or still pending — an interrupted worker can simply be restarted, and other connections can watch progress while a batch runs. + ## Agent Memory Sync Multiple agents can share and merge knowledge without any coordination. Each agent works independently with its own local SQLite database, syncing through a shared [SQLiteCloud](https://sqlitecloud.io/) managed database when connectivity is available. @@ -192,11 +289,11 @@ SELECT memory_add_text('Agent A findings...', 'research'); SELECT cloudsync_network_sync(500, 3); SELECT cloudsync_network_sync(500, 3); --- Generate embeddings for any content received from other agents +-- Refresh hashes and embeddings for any content received or merged from other agents SELECT memory_reindex(); ``` -Each piece of text added to the database is parsed into chunks and tracked by a [block-level LWW CRDT algorithm](https://github.com/sqliteai/sqlite-sync?tab=readme-ov-file#block-level-lww), which merges line-level changes from concurrent agents without conflicts. Only the `dbmem_content` table is synced — embeddings are always generated locally after receiving new content. +Each piece of text added to the database is parsed into chunks and tracked by a [block-level LWW CRDT algorithm](https://github.com/sqliteai/sqlite-sync?tab=readme-ov-file#block-level-lww), which merges line-level changes from concurrent agents without conflicts. Only the portable `dbmem_content` table is synced — embeddings and local filesystem provenance are always local. After a sync merge changes `dbmem_content.value`, `memory_reindex()` recomputes stale content hashes and refreshes local embeddings. ### Why This Matters for AI Systems @@ -244,6 +341,7 @@ SELECT memory_set_option('search_oversample', 4); -- Fetch 4x candidates before -- File processing SELECT memory_set_option('extensions', 'md,txt,rst'); -- File types to index +SELECT memory_set_option('preserve_duplicate_paths', 1); -- Keep duplicate/empty virtual paths -- Embedding cache (enabled by default) SELECT memory_set_option('embedding_cache', 0); -- Disable cache @@ -300,7 +398,7 @@ make test DEFINES="-DTEST_SQLITE_EXTENSION" - **Local Engine**: Built-in llama.cpp for on-device embeddings (requires GGUF model) - **Remote Engine**: [vectors.space](https://vectors.space) API for cloud embeddings (requires free API key) -- **File I/O**: `memory_add_file` and `memory_add_directory` functions +- **File I/O**: `memory_add_file`, `memory_add_directory`, and `memory_materialize_files` functions You can also combine options manually: @@ -317,19 +415,31 @@ MIT License - see [LICENSE](LICENSE) for details. --- -## Part of the SQLite AI Ecosystem +## ☁️ Hosted version + +Need to share agent memory across devices, users, or workers? **[SQLite Cloud](https://sqlite.ai)** is the managed backend for SQLite-Memory — sync memory across a fleet of agents with auth, ACL, and observability. + +[**Start free →**](https://dashboard.sqlitecloud.io/auth/sign-in) + +--- + +## Part of the SQLite AI stack + +SQLite-Memory is one piece of a larger ecosystem that turns SQLite into a runtime for intelligent, distributed data: -This project is part of the **SQLite AI** ecosystem, a collection of extensions that bring modern AI capabilities to the world's most widely deployed database. The goal is to make SQLite the default data and inference engine for Edge AI applications. +**Data layer** +- [sqlite-vector](https://github.com/sqliteai/sqlite-vector) — ANN vector search inside SQLite +- [sqlite-sync](https://github.com/sqliteai/sqlite-sync) — Offline-first CRDT sync across devices +- [sqlite-columnar](https://github.com/sqliteai/sqlite-columnar) — Column-oriented analytics for OLAP queries +- [sqlite-js](https://github.com/sqliteai/sqlite-js) — Custom SQLite functions written in JavaScript -Other projects in the ecosystem include: +**AI layer** +- [sqlite-ai](https://github.com/sqliteai/sqlite-ai) — On-device LLM inference and embeddings +- [sqlite-agent](https://github.com/sqliteai/sqlite-agent) — Autonomous AI agents running inside SQLite +- [**sqlite-memory**](https://github.com/sqliteai/sqlite-memory) — Persistent, searchable memory for agents *(you are here)* +- [sqlite-mcp](https://github.com/sqliteai/sqlite-mcp) — Call MCP tools directly from SQL queries -- **[SQLite-AI](https://github.com/sqliteai/sqlite-ai)** - On-device inference and embedding generation directly inside SQLite. -- **[SQLite-Memory](https://github.com/sqliteai/sqlite-memory)** - Markdown-based AI agent memory with semantic search. -- **[SQLite-Vector](https://github.com/sqliteai/sqlite-vector)** - Ultra-efficient vector search for embeddings stored as BLOBs in standard SQLite tables. -- **[SQLite-Sync](https://github.com/sqliteai/sqlite-sync)** - Local-first CRDT-based synchronization for seamless, conflict-free data sync and real-time collaboration across devices. -- **[SQLite-Agent](https://github.com/sqliteai/sqlite-agent)** - Run autonomous AI agents directly from within SQLite databases. -- **[SQLite-MCP](https://github.com/sqliteai/sqlite-mcp)** - Connect SQLite databases to MCP servers and invoke their tools. -- **[SQLite-JS](https://github.com/sqliteai/sqlite-js)** - Create custom SQLite functions using JavaScript. -- **[Liteparser](https://github.com/sqliteai/liteparser)** - A highly efficient and fully compliant SQLite SQL parser. +**Managed platform** +- [SQLite Cloud](https://sqlite.ai) — Hosted SQLite with sync, auth, edge functions, and analytics. [Free tier →](https://dashboard.sqlitecloud.io/auth/sign-in) -Learn more at **[SQLite AI](https://sqlite.ai)**. +Built by [SQLite AI](https://sqlite.ai). Questions? [Open a discussion](https://github.com/sqliteai/sqlite-memory/discussions) or [contact us](https://sqlite.ai/support). diff --git a/cli/EXAMPLES.md b/cli/EXAMPLES.md new file mode 100644 index 0000000..f567ff2 --- /dev/null +++ b/cli/EXAMPLES.md @@ -0,0 +1,333 @@ +# sqlmem Examples + +Practical examples for common `sqlmem` workflows. + +## Initialize A Project + +Create `.sqlmem.json`, create the SQLite database, install required extensions, and configure the embedding model. + +```sh +sqlmem init --model /models/nomic-embed-text-v1.5.Q8_0.gguf +``` + +Use a custom extension cache directory: + +```sh +sqlmem init \ + --extensions-dir ~/.cache/sqlmem/extensions \ + --model /models/nomic-embed-text-v1.5.Q8_0.gguf +``` + +## Use Remote Embeddings + +When an API key is present, `sqlmem` configures sqlite-memory for remote embeddings. + +```sh +sqlmem init \ + --api-key "$sqlmem_API_KEY" \ + --model text-embedding-3-small +``` + +You can also set the API key through the environment: + +```sh +export sqlmem_API_KEY="..." +sqlmem init --model text-embedding-3-small +``` + +Precedence is: + +1. `--api-key` +2. `sqlmem_API_KEY` +3. `.sqlmem.json` + +## Add Sources + +Add a directory: + +```sh +sqlmem add ./docs +``` + +Add one Markdown file: + +```sh +sqlmem add ./README.md +``` + +Add multiple sources in one command: + +```sh +sqlmem add ./docs ./notes/project.md +``` + +Use repeated `--source` flags: + +```sh +sqlmem add -s ./docs -s ./notes/project.md +``` + +Attach a context label to added content: + +```sh +sqlmem add ./docs --context product-docs +``` + +## Add PDFs + +PDF indexing is disabled by default because it requires a separate conversion/OCR step. Enable it explicitly first: + +```sh +sqlmem config set pdf.enabled true +``` + +PDF files are then converted to Markdown before indexing. + +```sh +sqlmem add ./papers/sqlite-memory-overview.pdf +``` + +Use a custom PDF cache directory: + +```sh +sqlmem --pdf-cache-dir ~/.cache/sqlmem/pdf add ./papers/report.pdf +``` + +Disable PDF support again: + +```sh +sqlmem config set pdf.enabled false +``` + +## Search + +Search with a positional query: + +```sh +sqlmem search "hybrid search with sqlite" +``` + +Search with flags: + +```sh +sqlmem search -q "embedding cache behavior" --limit 5 +``` + +Return JSON for scripts: + +```sh +sqlmem search -q "vector extension load order" --limit 10 --json +``` + +Pipe JSON to `jq`: + +```sh +sqlmem search -q "pdf cache" --json | jq '.[].path' +``` + +## Watch Sources + +Watch sources already stored in `.sqlmem.json`: + +```sh +sqlmem watch +``` + +Watch explicit paths for the current session: + +```sh +sqlmem watch ./docs ./notes/project.md +``` + +Use a shorter debounce window: + +```sh +sqlmem watch --debounce 200ms +``` + +## Inspect Status + +Show database path, source count, embedding selection, PDF cache, and indexed counts: + +```sh +sqlmem status +``` + +Show the full configuration: + +```sh +sqlmem config +``` + +## Edit Configuration + +Set the default search limit: + +```sh +sqlmem config set options.max_results 10 +``` + +Lower the minimum score: + +```sh +sqlmem config set options.min_score 0.65 +``` + +Disable embedding cache: + +```sh +sqlmem config set options.embedding_cache false +``` + +Set supported indexed file extensions: + +```sh +sqlmem config set options.extensions "md,mdx,txt" +``` + +Opt into reStructuredText explicitly: + +```sh +sqlmem config set options.extensions "md,mdx,txt,rst" +``` + +## Manage Extensions + +Print the global extension cache path: + +```sh +sqlmem extensions path +``` + +Install required extensions: + +```sh +sqlmem extensions install +``` + +Install only sqlite-sync: + +```sh +sqlmem extensions install sync +``` + +List installed extension files: + +```sh +sqlmem extensions list +``` + +Update cached extensions: + +```sh +sqlmem extensions update +``` + +Use a GitHub token for higher release API limits: + +```sh +export GITHUB_TOKEN="..." +sqlmem extensions update +``` + +## MCP Server + +Start the MCP server over stdio: + +```sh +sqlmem mcp --transport stdio +``` + +Start the MCP server over HTTP: + +```sh +sqlmem mcp --transport http --addr 127.0.0.1:8765 +``` + +Available MCP tools: + +```text +memory_search +memory_add_file +memory_add_content +memory_add_directory +memory_add_text +memory_clear +memory_delete +memory_delete_context +memory_reindex +memory_status +``` + +## Remove Sources + +Remove a configured source from `.sqlmem.json`: + +```sh +sqlmem remove ./docs +``` + +## Reindex Or Clear + +Reindex all stored memory: + +```sh +sqlmem reindex +``` + +Clear all memory content: + +```sh +sqlmem clear +``` + +Reset the project by deleting the configured database and `.sqlmem.json`: + +```sh +sqlmem reset +``` + +## Interactive Mode + +Run without a subcommand to open the interactive prompt: + +```sh +sqlmem +``` + +Inside the prompt: + +```text +sqlmem> status +sqlmem> search "release notes" +sqlmem> add ./notes +sqlmem> quit +``` + +Command history is available with the up and down arrow keys. + +## Script Examples + +Fail if no results are returned: + +```sh +results="$(sqlmem search -q "database migration" --json)" +count="$(printf '%s' "$results" | jq 'length')" +test "$count" -gt 0 +``` + +Index all Markdown files changed in the current Git branch: + +```sh +git diff --name-only main...HEAD -- '*.md' '*.mdx' | +while IFS= read -r file; do + [ -f "$file" ] && sqlmem add "$file" +done +``` + +Create a project-local database name: + +```sh +sqlmem init --model /models/nomic-embed-text-v1.5.Q8_0.gguf +sqlmem config set database ".cache/project-memory.sqlite" +``` diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 0000000..e2c706e --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,12 @@ +BINARY := sqlmem + +.PHONY: build test clean + +build: + go build -o $(BINARY) ./cmd/sqlmem + +test: + go test ./... + +clean: + rm -f $(BINARY) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..3fc11ef --- /dev/null +++ b/cli/README.md @@ -0,0 +1,104 @@ +# sqlmem + +`sqlmem` manages SQLite Memory databases backed by Markdown sources. +The CLI handles config, extension download/loading, optional PDF conversion cache, watch mode, and MCP. Markdown parsing, chunking, embedding, schema, FTS, and vector search stay inside the `sqlite-memory` extension. + +## Build + +```sh +make build +``` + +## Quick Start + +```sh +sqlmem init --model /path/to/nomic-embed-text-v1.5.Q8_0.gguf +sqlmem add ./docs +sqlmem search -q "sqlite vector search" --limit 5 +``` + +Remote embeddings use vectors.space when an API key is present: + +```sh +sqlmem init --api-key "$sqlmem_API_KEY" --model text-embedding-3-small +``` + +API key precedence is: CLI flag, `sqlmem_API_KEY`, config. + +## Config + +`sqlmem init` creates `.sqlmem.json` in the project root. Edit it manually or use: + +```sh +sqlmem config +sqlmem config set options.max_results 10 +``` + +If no config is found, commands fail with: + +```text +No .sqlmem.json found. Run `sqlmem init` first. +``` + +## Extensions + +Extensions are cached globally: + +- macOS: `~/Library/Application Support/sqlmem/extensions/` +- Linux: `~/.local/share/sqlmem/extensions/` +- Windows: `%APPDATA%/sqlmem/extensions/` + +Override with `--extensions-dir` or `sqlmem_EXTENSIONS_DIR`. + +```sh +sqlmem extensions path +sqlmem extensions install +sqlmem extensions install sync +sqlmem extensions list +sqlmem extensions update +``` + +The load order is `sqlite-vector`, `sqlite-memory`, then optional `sqlite-sync`. + +## PDF + +PDF indexing is disabled by default because it needs a separate conversion/OCR step. Enable it explicitly before adding PDF files: + +```sh +sqlmem config set pdf.enabled true +``` + +PDFs are then converted to Markdown before indexing. The default converter shells out to `glm-ocr` and stores cache entries under the global PDF cache: + +```text +// + source.json + content.md +``` + +Override with `--pdf-cache-dir` or `sqlmem_PDF_CACHE_DIR`. + +## Commands + +```sh +sqlmem add ./docs +sqlmem add ./file.pdf +sqlmem add -s ./docs -s ./file.pdf +sqlmem search -q "query" --json +sqlmem watch +sqlmem mcp --transport stdio +sqlmem mcp --transport http --addr 127.0.0.1:8765 +sqlmem status +sqlmem clear +sqlmem reindex +sqlmem remove ./docs +sqlmem reset +``` + +Running `sqlmem` without arguments opens an interactive prompt with command history and arrow-key navigation. + +## Test + +```sh +make test +``` diff --git a/cli/cmd/sqlmem/main.go b/cli/cmd/sqlmem/main.go new file mode 100644 index 0000000..3bdbd7a --- /dev/null +++ b/cli/cmd/sqlmem/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + sqlmemcli "github.com/sqliteai/sqlite-memory/cli/internal/cli" +) + +func main() { + if err := sqlmemcli.NewRootCommand().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..de2ea47 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,16 @@ +module github.com/sqliteai/sqlite-memory/cli + +go 1.23 + +require ( + github.com/chzyer/readline v1.5.1 + github.com/fsnotify/fsnotify v1.9.0 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/spf13/cobra v1.10.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..0095082 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,23 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/cli/root.go b/cli/internal/cli/root.go new file mode 100644 index 0000000..0d4ae29 --- /dev/null +++ b/cli/internal/cli/root.go @@ -0,0 +1,660 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/chzyer/readline" + "github.com/spf13/cobra" + "github.com/sqliteai/sqlite-memory/cli/internal/config" + "github.com/sqliteai/sqlite-memory/cli/internal/download" + "github.com/sqliteai/sqlite-memory/cli/internal/mcp" + "github.com/sqliteai/sqlite-memory/cli/internal/memory" + "github.com/sqliteai/sqlite-memory/cli/internal/output" + "github.com/sqliteai/sqlite-memory/cli/internal/pdf" + sqlitemem "github.com/sqliteai/sqlite-memory/cli/internal/sqlite" + watchpkg "github.com/sqliteai/sqlite-memory/cli/internal/watch" +) + +type globalFlags struct { + extensionsDir string + pdfCacheDir string + apiKey string + provider string + model string +} + +func NewRootCommand() *cobra.Command { + flags := &globalFlags{} + root := &cobra.Command{ + Use: "sqlmem", + Short: "Manage SQLite Memory databases backed by Markdown documents", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runInteractive(flags) + }, + } + root.PersistentFlags().StringVar(&flags.extensionsDir, "extensions-dir", "", "SQLite extension cache directory") + root.PersistentFlags().StringVar(&flags.pdfCacheDir, "pdf-cache-dir", "", "PDF markdown cache directory") + root.PersistentFlags().StringVar(&flags.apiKey, "api-key", "", "vectors.space API key") + root.PersistentFlags().StringVar(&flags.provider, "provider", "", "embedding provider") + root.PersistentFlags().StringVar(&flags.model, "model", "", "embedding model") + + root.AddCommand( + initCmd(flags), + addCmd(flags), + searchCmd(flags), + watchCmd(flags), + mcpCmd(flags), + configCmd(), + removeCmd(), + clearCmd(flags), + reindexCmd(flags), + statusCmd(flags), + resetCmd(), + extensionsCmd(flags), + ) + return root +} + +func initCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize a sqlmem project", + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfgPath := filepath.Join(".", config.FileName) + if _, err := os.Stat(cfgPath); err == nil { + return fmt.Errorf("%s already exists", config.FileName) + } + cfg := config.Default() + if flags.extensionsDir != "" { + cfg.ExtensionsDir = flags.extensionsDir + } + if flags.apiKey != "" { + cfg.Embedding.APIKey = flags.apiKey + } + if flags.provider != "" { + cfg.Embedding.Provider = flags.provider + } + if flags.model != "" { + cfg.Embedding.Model = flags.model + } + ctx := cmd.Context() + if err := installRequiredExtensions(ctx, cfg, flags.extensionsDir, []string{"vector", "memory"}); err != nil { + return err + } + db, err := sqlitemem.Open(ctx, sqlitemem.OpenOptions{Config: cfg, ConfigPath: cfgPath, ExtensionsDir: flags.extensionsDir}) + if err != nil { + return err + } + defer db.Close() + if err := memory.Configure(ctx, db, cfg, memory.ModelOptions{Provider: flags.provider, Model: flags.model, APIKey: flags.apiKey}); err != nil { + return err + } + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Initialized sqlmem project in %d ms\n", elapsedMS(start)) + return nil + }, + } +} + +func addCmd(flags *globalFlags) *cobra.Command { + var sources []string + var contextLabel string + cmd := &cobra.Command{ + Use: "add [path...]", + Short: "Add files or directories", + RunE: func(cmd *cobra.Command, args []string) error { + all := append([]string{}, args...) + all = append(all, sources...) + if len(all) == 0 { + return fmt.Errorf("no sources provided") + } + ctx := cmd.Context() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + db, err := openConfigured(ctx, cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + for _, source := range all { + start := time.Now() + storedSource, indexSource, err := config.NormalizeSource(cfgPath, source) + if err != nil { + return err + } + if config.HasSource(cfg, cfgPath, storedSource) { + if config.AddSource(&cfg, cfgPath, storedSource) { + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "Source already added: %s in %d ms\n", storedSource, elapsedMS(start)) + continue + } + spin := output.NewSpinner("indexing") + spin.Start() + err = addSource(ctx, db, cfg, flags, indexSource, contextLabel) + spin.Stop() + if err != nil { + return err + } + config.AddSource(&cfg, cfgPath, storedSource) + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Added source: %s in %d ms\n", storedSource, elapsedMS(start)) + } + return nil + }, + } + cmd.Flags().StringArrayVarP(&sources, "source", "s", nil, "source file or directory") + cmd.Flags().StringVar(&contextLabel, "context", "", "context label") + return cmd +} + +func searchCmd(flags *globalFlags) *cobra.Command { + var query string + var limit int + var jsonOut bool + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Search memory", + RunE: func(cmd *cobra.Command, args []string) error { + if query == "" { + query = strings.Join(args, " ") + } + if query == "" { + return fmt.Errorf("query required") + } + ctx := cmd.Context() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + db, err := openConfigured(ctx, cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + start := time.Now() + spin := output.NewSpinner("searching") + spin.Start() + results, err := memory.Search(ctx, db, query, limit) + spin.Stop() + elapsed := elapsedMS(start) + if err != nil { + return err + } + if jsonOut { + fmt.Fprintln(cmd.OutOrStdout(), memory.ResultsJSON(results)) + fmt.Fprintf(cmd.ErrOrStderr(), "Search returned %d results in %d ms\n", len(results), elapsed) + } else { + for _, r := range results { + fmt.Fprintln(cmd.OutOrStdout(), memory.FormatResult(r)) + fmt.Fprintln(cmd.OutOrStdout()) + } + fmt.Fprintf(cmd.OutOrStdout(), "Search returned %d results in %d ms\n", len(results), elapsed) + } + return nil + }, + } + cmd.Flags().StringVarP(&query, "query", "q", "", "search query") + cmd.Flags().IntVar(&limit, "limit", 0, "result limit") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + return cmd +} + +func watchCmd(flags *globalFlags) *cobra.Command { + var debounce time.Duration + cmd := &cobra.Command{ + Use: "watch", + Short: "Watch configured sources", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + sources := []string{} + for _, source := range cfg.Sources { + resolved, err := config.ResolveSource(cfgPath, source) + if err != nil { + return err + } + sources = append(sources, resolved) + } + if len(args) > 0 { + sources = []string{} + for _, source := range args { + _, resolved, err := config.NormalizeSource(cfgPath, source) + if err != nil { + return err + } + sources = append(sources, resolved) + } + } + if len(sources) == 0 { + return fmt.Errorf("no sources configured") + } + ctx := cmd.Context() + db, err := openConfigured(ctx, cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + fmt.Fprintf(cmd.OutOrStdout(), "Watching %d sources\n", len(sources)) + return watchpkg.Run(ctx, sources, debounce, func(ctx context.Context, path string, removed bool) error { + if removed { + return removeIndexedSource(ctx, db, cfg, flags, sources, path) + } + return addSource(ctx, db, cfg, flags, path, "") + }) + }, + } + cmd.Flags().DurationVar(&debounce, "debounce", 500*time.Millisecond, "debounce duration") + return cmd +} + +func mcpCmd(flags *globalFlags) *cobra.Command { + var transport string + var addr string + cmd := &cobra.Command{ + Use: "mcp", + Short: "Start MCP server", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + db, err := openConfigured(ctx, cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + server := mcp.Server{DB: db} + fmt.Fprintln(cmd.ErrOrStderr(), "MCP server started") + if transport == "http" { + return server.ServeHTTP(ctx, addr) + } + return server.ServeStdio(ctx, os.Stdin, os.Stdout) + }, + } + cmd.Flags().StringVar(&transport, "transport", "stdio", "stdio or http") + cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8765", "HTTP listen address") + return cmd +} + +func configCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Show config", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, err := loadConfig() + if err != nil { + return err + } + data, _ := json.MarshalIndent(cfg, "", " ") + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + }, + } + cmd.AddCommand(&cobra.Command{ + Use: "set KEY VALUE", + Short: "Set config value", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + if err := config.SetDot(&cfg, args[0], args[1]); err != nil { + return err + } + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Updated config: %s in %d ms\n", args[0], elapsedMS(start)) + return nil + }, + }) + return cmd +} + +func removeCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove SOURCE", + Short: "Remove a configured source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + source, _, err := config.NormalizeSource(cfgPath, args[0]) + if err != nil { + return err + } + if !config.RemoveSource(&cfg, cfgPath, source) { + return fmt.Errorf("source not found: %s", source) + } + if err := config.Save(cfgPath, cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed source: %s in %d ms\n", source, elapsedMS(start)) + return nil + }, + } +} + +func clearCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "clear", + Short: "Clear memory", + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + db, err := openConfigured(cmd.Context(), cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + if err := memory.Clear(cmd.Context(), db); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Cleared memory in %d ms\n", elapsedMS(start)) + return nil + }, + } +} + +func reindexCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "reindex", + Short: "Reindex memory", + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + db, err := openConfigured(cmd.Context(), cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + if err := memory.Reindex(cmd.Context(), db); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Reindexed memory in %d ms\n", elapsedMS(start)) + return nil + }, + } +} + +func statusCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show project status", + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "db: %s\n", config.DatabasePath(cfgPath, cfg)) + fmt.Fprintf(cmd.OutOrStdout(), "sources: %d\n", len(cfg.Sources)) + model := memory.ResolveModel(cfg, memory.ModelOptions{Provider: flags.provider, Model: flags.model, APIKey: flags.apiKey}) + fmt.Fprintf(cmd.OutOrStdout(), "embedding: %s %s\n", model.Provider, model.Model) + fmt.Fprintf(cmd.OutOrStdout(), "pdf: enabled=%v cache=%s\n", cfg.PDF.Enabled, config.ResolvePDFCacheDir(cfg, flags.pdfCacheDir)) + db, err := openConfigured(cmd.Context(), cfg, cfgPath, flags) + if err != nil { + return err + } + defer db.Close() + st, _ := memory.Status(cmd.Context(), db) + fmt.Fprintf(cmd.OutOrStdout(), "memories: %v\nchunks: %v\n", st["memories"], st["chunks"]) + fmt.Fprintf(cmd.OutOrStdout(), "Status collected in %d ms\n", elapsedMS(start)) + return nil + }, + } +} + +func resetCmd() *cobra.Command { + return &cobra.Command{ + Use: "reset", + Short: "Reset project", + RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() + cfg, cfgPath, err := loadConfig() + if err != nil { + return err + } + _ = os.Remove(config.DatabasePath(cfgPath, cfg)) + _ = os.Remove(cfgPath) + fmt.Fprintf(cmd.OutOrStdout(), "Reset sqlmem project in %d ms\n", elapsedMS(start)) + return nil + }, + } +} + +func extensionsCmd(flags *globalFlags) *cobra.Command { + cmd := &cobra.Command{Use: "extensions", Short: "Manage SQLite extensions"} + cmd.AddCommand(&cobra.Command{ + Use: "path", + Short: "Show extension cache path", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, _ := loadConfig() + fmt.Fprintln(cmd.OutOrStdout(), config.ResolveExtensionsDir(cfg, flags.extensionsDir)) + return nil + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed extensions", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, _ := loadConfig() + base := config.ResolveExtensionsDir(cfg, flags.extensionsDir) + return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), path) + return nil + }) + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "install [vector|memory|sync]", + Short: "Install extensions", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, _ := loadConfig() + names := args + if len(names) == 0 { + names = []string{"vector", "memory"} + } + return installRequiredExtensions(cmd.Context(), cfg, flags.extensionsDir, names) + }, + }) + cmd.AddCommand(&cobra.Command{ + Use: "update [vector|memory|sync]", + Short: "Update extensions", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _, _ := loadConfig() + names := args + if len(names) == 0 { + names = []string{"vector", "memory"} + } + return installRequiredExtensions(cmd.Context(), cfg, flags.extensionsDir, names) + }, + }) + return cmd +} + +func loadConfig() (config.Config, string, error) { + cfg, path, err := config.LoadFrom(".") + if err != nil { + if errors.Is(err, config.ErrNotFound) { + return config.Config{}, "", config.ErrNotFound + } + return config.Config{}, "", err + } + return cfg, path, nil +} + +func openConfigured(ctx context.Context, cfg config.Config, cfgPath string, flags *globalFlags) (*sql.DB, error) { + db, err := sqlitemem.Open(ctx, sqlitemem.OpenOptions{Config: cfg, ConfigPath: cfgPath, ExtensionsDir: flags.extensionsDir}) + if err != nil { + return nil, err + } + if err := memory.Configure(ctx, db, cfg, memory.ModelOptions{Provider: flags.provider, Model: flags.model, APIKey: flags.apiKey}); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func addSource(ctx context.Context, db *sql.DB, cfg config.Config, flags *globalFlags, source, contextLabel string) error { + info, err := os.Stat(source) + if err != nil { + return err + } + if info.IsDir() { + return memory.AddDirectory(ctx, db, source, contextLabel) + } + if strings.EqualFold(filepath.Ext(source), ".pdf") { + if !cfg.PDF.Enabled { + return fmt.Errorf("PDF support disabled") + } + cache := pdf.Cache{ + Dir: config.ResolvePDFCacheDir(cfg, flags.pdfCacheDir), + Force: cfg.PDF.Force, + } + result, err := cache.Process(ctx, source) + if err != nil { + return err + } + if contextLabel == "" { + contextLabel = source + } + return memory.AddFile(ctx, db, result.IndexPath, contextLabel) + } + return memory.AddFile(ctx, db, source, contextLabel) +} + +func removeIndexedSource(ctx context.Context, db *sql.DB, cfg config.Config, flags *globalFlags, roots []string, source string) error { + path := source + if strings.EqualFold(filepath.Ext(source), ".pdf") { + path = pdf.IndexPathForSource(config.ResolvePDFCacheDir(cfg, flags.pdfCacheDir), source) + } + if err := memory.DeletePath(ctx, db, path); err != nil { + return err + } + for _, root := range roots { + rel, ok := relativeIndexedPath(root, source) + if !ok { + continue + } + if err := memory.DeletePath(ctx, db, rel); err != nil { + return err + } + } + return nil +} + +func relativeIndexedPath(root, source string) (string, bool) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(source)) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return filepath.ToSlash(rel), true +} + +func installRequiredExtensions(ctx context.Context, cfg config.Config, override string, names []string) error { + base := config.ResolveExtensionsDir(cfg, override) + client := download.Client{} + for _, name := range names { + start := time.Now() + repo, ok := map[string]string{"vector": download.RepoVector, "memory": download.RepoMemory, "sync": download.RepoSync}[name] + if !ok { + return fmt.Errorf("unknown extension %s", name) + } + version := cfg.ExtensionVersions[name] + if version == "" { + version = cfg.Extensions[name] + } + if version == "" { + version = "latest" + } + path, err := client.Install(ctx, "sqliteai", repo, version, base) + if err != nil { + return err + } + fmt.Printf("Installed %s: %s in %d ms\n", name, path, elapsedMS(start)) + } + return nil +} + +func elapsedMS(start time.Time) int64 { + return time.Since(start).Milliseconds() +} + +func runInteractive(flags *globalFlags) error { + history := filepath.Join(config.DefaultExtensionsDir(), "..", "history") + if err := os.MkdirAll(filepath.Dir(history), 0755); err != nil { + return err + } + rl, err := readline.NewEx(&readline.Config{ + Prompt: "sqlmem> ", + HistoryFile: history, + }) + if err != nil { + return err + } + defer rl.Close() + for { + line, err := rl.Readline() + if errors.Is(err, readline.ErrInterrupt) { + continue + } + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line == "exit" || line == "quit" { + return nil + } + args := strings.Fields(line) + cmd := NewRootCommand() + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + } + } +} diff --git a/cli/internal/cli/root_test.go b/cli/internal/cli/root_test.go new file mode 100644 index 0000000..a063e2e --- /dev/null +++ b/cli/internal/cli/root_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/sqliteai/sqlite-memory/cli/internal/config" +) + +func TestStatusReturnsOpenError(t *testing.T) { + dir := t.TempDir() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + cfg := config.Default() + cfg.Extensions["vector"] = filepath.Join(dir, "missing-vector") + cfg.Extensions["memory"] = filepath.Join(dir, "missing-memory") + if err := config.Save(config.FileName, cfg); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"status"}) + cmd.SetOut(&bytes.Buffer{}) + if err := cmd.Execute(); err == nil { + t.Fatal("status returned nil error for missing extensions") + } +} + +func TestRelativeIndexedPath(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "docs", "readme.md") + got, ok := relativeIndexedPath(root, source) + if !ok { + t.Fatal("relativeIndexedPath returned false") + } + if got != "docs/readme.md" { + t.Fatalf("relativeIndexedPath() = %q", got) + } +} + +func TestRelativeIndexedPathRejectsSibling(t *testing.T) { + root := t.TempDir() + source := filepath.Join(filepath.Dir(root), "sibling.md") + if got, ok := relativeIndexedPath(root, source); ok { + t.Fatalf("relativeIndexedPath() = %q, true", got) + } +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go new file mode 100644 index 0000000..0e26411 --- /dev/null +++ b/cli/internal/config/config.go @@ -0,0 +1,503 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "time" +) + +const FileName = ".sqlmem.json" + +var ErrNotFound = errors.New("No .sqlmem.json found. Run `sqlmem init` first.") + +type Config struct { + Database string `json:"database"` + ExtensionsDir string `json:"extensions_dir"` + Extensions map[string]string `json:"extensions"` + ExtensionVersions map[string]string `json:"extension_versions"` + Sources []string `json:"sources"` + Embedding EmbeddingConfig `json:"embedding"` + PDF PDFConfig `json:"pdf"` + Options Options `json:"options"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type EmbeddingConfig struct { + Provider string `json:"provider"` + Model string `json:"model"` + APIKey string `json:"api_key"` +} + +type PDFConfig struct { + Enabled bool `json:"enabled"` + Model string `json:"model"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` + CacheDir string `json:"cache_dir"` + Force bool `json:"force"` +} + +type Options struct { + MaxTokens int `json:"max_tokens"` + OverlayTokens int `json:"overlay_tokens"` + MaxResults int `json:"max_results"` + MinScore float64 `json:"min_score"` + VectorWeight float64 `json:"vector_weight"` + TextWeight float64 `json:"text_weight"` + SearchOversample int `json:"search_oversample"` + Extensions string `json:"extensions"` + EmbeddingCache bool `json:"embedding_cache"` + CacheMaxEntries int `json:"cache_max_entries"` +} + +func Default() Config { + now := time.Now().UTC().Format(time.RFC3339) + return Config{ + Database: "memory.sqlite", + ExtensionsDir: "", + Extensions: map[string]string{ + "vector": "latest", + "memory": "latest", + "sync": "", + }, + ExtensionVersions: map[string]string{ + "vector": "latest", + "memory": "latest", + "sync": "latest", + }, + Sources: []string{}, + Embedding: EmbeddingConfig{ + Provider: "local", + Model: "", + APIKey: "", + }, + PDF: PDFConfig{ + Enabled: false, + Provider: "local", + }, + Options: Options{ + MaxTokens: 512, + OverlayTokens: 100, + MaxResults: 30, + MinScore: 0.75, + VectorWeight: 0.6, + TextWeight: 0.4, + SearchOversample: 4, + Extensions: "md,mdx,txt", + EmbeddingCache: true, + CacheMaxEntries: 10000, + }, + CreatedAt: now, + UpdatedAt: now, + } +} + +func Load(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Config{}, ErrNotFound + } + return Config{}, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + applyDefaults(data, &cfg) + return cfg, nil +} + +func Save(path string, cfg Config) error { + if cfg.CreatedAt == "" { + cfg.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + cfg.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0644) +} + +func Find(start string) (string, error) { + dir, err := filepath.Abs(start) + if err != nil { + return "", err + } + for { + path := filepath.Join(dir, FileName) + if _, err := os.Stat(path); err == nil { + return path, nil + } + next := filepath.Dir(dir) + if next == dir { + return "", ErrNotFound + } + dir = next + } +} + +func LoadFrom(start string) (Config, string, error) { + path, err := Find(start) + if err != nil { + return Config{}, "", err + } + cfg, err := Load(path) + return cfg, path, err +} + +func DefaultExtensionsDir() string { + if v := firstEnv("sqlmem_EXTENSIONS_DIR", "SQLMEM_EXTENSIONS_DIR"); v != "" { + return v + } + base := dataRoot() + return filepath.Join(base, "extensions") +} + +func DefaultPDFCacheDir() string { + if v := firstEnv("sqlmem_PDF_CACHE_DIR", "SQLMEM_PDF_CACHE_DIR"); v != "" { + return v + } + base := dataRoot() + return filepath.Join(base, "pdf-cache") +} + +func ResolveExtensionsDir(cfg Config, override string) string { + if override != "" { + return override + } + if v := firstEnv("sqlmem_EXTENSIONS_DIR", "SQLMEM_EXTENSIONS_DIR"); v != "" { + return v + } + if cfg.ExtensionsDir != "" { + return cfg.ExtensionsDir + } + return DefaultExtensionsDir() +} + +func ResolvePDFCacheDir(cfg Config, override string) string { + if override != "" { + return override + } + if v := firstEnv("sqlmem_PDF_CACHE_DIR", "SQLMEM_PDF_CACHE_DIR"); v != "" { + return v + } + if cfg.PDF.CacheDir != "" { + return cfg.PDF.CacheDir + } + return DefaultPDFCacheDir() +} + +func ResolveAPIKey(cfg Config, cliValue string) string { + if cliValue != "" { + return cliValue + } + if v := firstEnv("sqlmem_API_KEY", "SQLMEM_API_KEY"); v != "" { + return v + } + return cfg.Embedding.APIKey +} + +func SetDot(cfg *Config, key string, raw string) error { + if key == "" { + return fmt.Errorf("empty config key") + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return err + } + parts := strings.Split(key, ".") + cur := m + for _, part := range parts[:len(parts)-1] { + next, ok := cur[part].(map[string]any) + if !ok { + return fmt.Errorf("unknown config key %q", key) + } + cur = next + } + leaf := parts[len(parts)-1] + if _, ok := cur[leaf]; !ok { + return fmt.Errorf("unknown config key %q", key) + } + cur[leaf] = parseValue(raw, cur[leaf]) + data, err = json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(data, cfg); err != nil { + return err + } + applyDefaults(data, cfg) + return nil +} + +func NormalizeSource(configPath, source string) (string, string, error) { + resolved, err := normalizeUserSource(source) + if err != nil { + return "", "", err + } + cfgDir, err := configDir(configPath) + if err != nil { + return "", "", err + } + stored, err := filepath.Rel(cfgDir, resolved) + if err != nil { + return "", "", err + } + return filepath.Clean(stored), resolved, nil +} + +func ResolveSource(configPath, source string) (string, error) { + if filepath.IsAbs(source) { + return normalizeExistingPath(source), nil + } + cfgDir, err := configDir(configPath) + if err != nil { + return "", err + } + return normalizeExistingPath(filepath.Join(cfgDir, source)), nil +} + +func HasSource(cfg Config, configPath, source string) bool { + source = normalizeConfigSource(configPath, source) + for _, existing := range cfg.Sources { + if normalizeConfigSource(configPath, existing) == source { + return true + } + } + return false +} + +func AddSource(cfg *Config, configPath, source string) bool { + source = filepath.Clean(source) + normalized := normalizeConfigSource(configPath, source) + next := cfg.Sources[:0] + changed := false + found := false + for _, existing := range cfg.Sources { + if normalizeConfigSource(configPath, existing) == normalized { + if !found { + next = append(next, source) + found = true + changed = changed || existing != source + } else { + changed = true + } + continue + } + next = append(next, existing) + } + if !found { + next = append(next, source) + changed = true + } + cfg.Sources = next + return changed +} + +func RemoveSource(cfg *Config, configPath, source string) bool { + source = normalizeConfigSource(configPath, source) + next := cfg.Sources[:0] + removed := false + for _, existing := range cfg.Sources { + if normalizeConfigSource(configPath, existing) == source { + removed = true + continue + } + next = append(next, existing) + } + cfg.Sources = next + return removed +} + +func configDir(configPath string) (string, error) { + dir, err := filepath.Abs(filepath.Dir(configPath)) + if err != nil { + return "", err + } + return normalizeExistingPath(dir), nil +} + +func normalizeUserSource(source string) (string, error) { + if abs, err := filepath.Abs(source); err == nil { + return normalizeExistingPath(abs), nil + } else { + return "", err + } +} + +func normalizeConfigSource(configPath, source string) string { + resolved, err := ResolveSource(configPath, source) + if err != nil { + return filepath.Clean(source) + } + return resolved +} + +func normalizeExistingPath(source string) string { + if resolved, err := filepath.EvalSymlinks(source); err == nil { + source = resolved + } + return filepath.Clean(source) +} + +func DatabasePath(configPath string, cfg Config) string { + if filepath.IsAbs(cfg.Database) { + return cfg.Database + } + return filepath.Join(filepath.Dir(configPath), cfg.Database) +} + +func firstEnv(names ...string) string { + for _, name := range names { + if v := os.Getenv(name); v != "" { + return v + } + } + return "" +} + +func dataRoot() string { + home, err := os.UserHomeDir() + if err != nil { + return "." + } + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "sqlmem") + case "windows": + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "sqlmem") + } + return filepath.Join(home, "AppData", "Roaming", "sqlmem") + default: + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "sqlmem") + } + return filepath.Join(home, ".local", "share", "sqlmem") + } +} + +func applyDefaults(data []byte, cfg *Config) { + def := Default() + presence := fieldPresence(data) + if cfg.Database == "" { + cfg.Database = def.Database + } + if cfg.Extensions == nil { + cfg.Extensions = def.Extensions + } + if cfg.ExtensionVersions == nil { + cfg.ExtensionVersions = def.ExtensionVersions + } + if cfg.Sources == nil { + cfg.Sources = []string{} + } + if cfg.Embedding.Provider == "" { + cfg.Embedding.Provider = "local" + } + if !presence.PDF["enabled"] { + cfg.PDF.Enabled = def.PDF.Enabled + } + if cfg.PDF.Provider == "" { + cfg.PDF.Provider = "local" + } + if !presence.Options["max_tokens"] { + cfg.Options.MaxTokens = def.Options.MaxTokens + } + if !presence.Options["overlay_tokens"] { + cfg.Options.OverlayTokens = def.Options.OverlayTokens + } + if !presence.Options["max_results"] { + cfg.Options.MaxResults = def.Options.MaxResults + } + if !presence.Options["min_score"] { + cfg.Options.MinScore = def.Options.MinScore + } + if !presence.Options["vector_weight"] { + cfg.Options.VectorWeight = def.Options.VectorWeight + } + if !presence.Options["text_weight"] { + cfg.Options.TextWeight = def.Options.TextWeight + } + if !presence.Options["search_oversample"] { + cfg.Options.SearchOversample = def.Options.SearchOversample + } + if !presence.Options["extensions"] { + cfg.Options.Extensions = def.Options.Extensions + } + if !presence.Options["embedding_cache"] { + cfg.Options.EmbeddingCache = def.Options.EmbeddingCache + } + if !presence.Options["cache_max_entries"] { + cfg.Options.CacheMaxEntries = def.Options.CacheMaxEntries + } + if cfg.CreatedAt == "" { + cfg.CreatedAt = def.CreatedAt + } + if cfg.UpdatedAt == "" { + cfg.UpdatedAt = def.UpdatedAt + } +} + +type jsonPresence struct { + Options map[string]bool + PDF map[string]bool +} + +func fieldPresence(data []byte) jsonPresence { + presence := jsonPresence{ + Options: map[string]bool{}, + PDF: map[string]bool{}, + } + var raw struct { + Options map[string]json.RawMessage `json:"options"` + PDF map[string]json.RawMessage `json:"pdf"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return presence + } + for key := range raw.Options { + presence.Options[key] = true + } + for key := range raw.PDF { + presence.PDF[key] = true + } + return presence +} + +func parseValue(raw string, existing any) any { + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + return parsed + } + switch reflect.TypeOf(existing).Kind() { + case reflect.Bool: + if v, err := strconv.ParseBool(raw); err == nil { + return v + } + case reflect.Float64: + if v, err := strconv.ParseFloat(raw, 64); err == nil { + return v + } + case reflect.Int: + if v, err := strconv.Atoi(raw); err == nil { + return v + } + } + return raw +} diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go new file mode 100644 index 0000000..96a89bd --- /dev/null +++ b/cli/internal/config/config_test.go @@ -0,0 +1,228 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, FileName) + cfg := Default() + cfg.Database = "test.sqlite" + cfg.Sources = []string{"docs"} + if err := Save(path, cfg); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.Database != "test.sqlite" { + t.Fatalf("database = %q", got.Database) + } + if len(got.Sources) != 1 || got.Sources[0] != "docs" { + t.Fatalf("sources = %#v", got.Sources) + } +} + +func TestDefaultExtensionsAreConservative(t *testing.T) { + if got := Default().Options.Extensions; got != "md,mdx,txt" { + t.Fatalf("extensions = %q", got) + } + if Default().PDF.Enabled { + t.Fatal("pdf should be disabled by default") + } +} + +func TestSetDot(t *testing.T) { + cfg := Default() + if err := SetDot(&cfg, "options.max_results", "7"); err != nil { + t.Fatal(err) + } + if cfg.Options.MaxResults != 7 { + t.Fatalf("max_results = %d", cfg.Options.MaxResults) + } + if err := SetDot(&cfg, "embedding.model", "abc"); err != nil { + t.Fatal(err) + } + if cfg.Embedding.Model != "abc" { + t.Fatalf("model = %q", cfg.Embedding.Model) + } + if err := SetDot(&cfg, "options.cache_max_entries", "0"); err != nil { + t.Fatal(err) + } + if cfg.Options.CacheMaxEntries != 0 { + t.Fatalf("cache_max_entries = %d", cfg.Options.CacheMaxEntries) + } +} + +func TestSourceNormalizationAndDedup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "docs") + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, FileName) + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + stored, resolved, err := NormalizeSource(cfgPath, "./docs") + if err != nil { + t.Fatal(err) + } + expected, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + if stored != "docs" { + t.Fatalf("stored = %q", stored) + } + if resolved != expected { + t.Fatalf("resolved = %q", resolved) + } + cfg := Default() + if !AddSource(&cfg, cfgPath, stored) { + t.Fatal("first source was not added") + } + if AddSource(&cfg, cfgPath, filepath.Join(".", "docs")) { + t.Fatal("duplicate source was added") + } + if len(cfg.Sources) != 1 { + t.Fatalf("sources = %#v", cfg.Sources) + } + if cfg.Sources[0] != "docs" { + t.Fatalf("source was stored as %q", cfg.Sources[0]) + } + if !RemoveSource(&cfg, cfgPath, filepath.Join(".", "docs")) { + t.Fatal("source was not removed") + } +} + +func TestAddSourceRewritesEquivalentAbsoluteSource(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, FileName) + docs := filepath.Join(dir, "docs") + if err := os.Mkdir(docs, 0755); err != nil { + t.Fatal(err) + } + cfg := Default() + cfg.Sources = []string{docs, "docs"} + + if !AddSource(&cfg, cfgPath, "docs") { + t.Fatal("equivalent absolute source was not rewritten") + } + if len(cfg.Sources) != 1 || cfg.Sources[0] != "docs" { + t.Fatalf("sources = %#v", cfg.Sources) + } +} + +func TestSourceNormalizationFromSubdirectory(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, FileName) + subdir := filepath.Join(dir, "subdir") + docs := filepath.Join(dir, "docs") + if err := os.Mkdir(subdir, 0755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(docs, 0755); err != nil { + t.Fatal(err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + }) + if err := os.Chdir(subdir); err != nil { + t.Fatal(err) + } + + stored, resolved, err := NormalizeSource(cfgPath, "../docs") + if err != nil { + t.Fatal(err) + } + if stored != "docs" { + t.Fatalf("stored = %q", stored) + } + if resolved != normalizeExistingPath(docs) { + t.Fatalf("resolved = %q", resolved) + } +} + +func TestAPIKeyPrecedence(t *testing.T) { + t.Setenv("sqlmem_API_KEY", "env") + cfg := Default() + cfg.Embedding.APIKey = "config" + if got := ResolveAPIKey(cfg, "cli"); got != "cli" { + t.Fatalf("cli key not preferred: %q", got) + } + if got := ResolveAPIKey(cfg, ""); got != "env" { + t.Fatalf("env key not preferred: %q", got) + } + os.Unsetenv("sqlmem_API_KEY") + if got := ResolveAPIKey(cfg, ""); got != "config" { + t.Fatalf("config key not used: %q", got) + } +} + +func TestDefaultCacheDirs(t *testing.T) { + t.Setenv("sqlmem_EXTENSIONS_DIR", "/tmp/sqlmem-ext") + t.Setenv("sqlmem_PDF_CACHE_DIR", "/tmp/sqlmem-pdf") + if got := DefaultExtensionsDir(); got != "/tmp/sqlmem-ext" { + t.Fatalf("extensions dir = %q", got) + } + if got := DefaultPDFCacheDir(); got != "/tmp/sqlmem-pdf" { + t.Fatalf("pdf cache dir = %q", got) + } +} + +func TestLoadPreservesExplicitZeroAndFalse(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, FileName) + raw := `{ + "database": "memory.sqlite", + "pdf": {"enabled": false}, + "options": { + "cache_max_entries": 0, + "min_score": 0, + "embedding_cache": false + } +}` + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.PDF.Enabled { + t.Fatal("pdf.enabled was defaulted over explicit false") + } + if cfg.Options.CacheMaxEntries != 0 { + t.Fatalf("cache_max_entries = %d", cfg.Options.CacheMaxEntries) + } + if cfg.Options.MinScore != 0 { + t.Fatalf("min_score = %f", cfg.Options.MinScore) + } + if cfg.Options.EmbeddingCache { + t.Fatal("embedding_cache was defaulted over explicit false") + } + if cfg.Options.MaxResults != Default().Options.MaxResults { + t.Fatalf("missing max_results did not default: %d", cfg.Options.MaxResults) + } +} diff --git a/cli/internal/download/download.go b/cli/internal/download/download.go new file mode 100644 index 0000000..223340b --- /dev/null +++ b/cli/internal/download/download.go @@ -0,0 +1,348 @@ +package download + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + RepoVector = "sqlite-vector" + RepoMemory = "sqlite-memory" + RepoSync = "sqlite-sync" +) + +type Client struct { + HTTP *http.Client + Platform Platform +} + +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type release struct { + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +func (c Client) Install(ctx context.Context, owner, repo, version, baseDir string) (string, error) { + if version == "" { + version = "latest" + } + if c.HTTP == nil { + c.HTTP = http.DefaultClient + } + if c.Platform.OS == "" { + c.Platform = CurrentPlatform() + } + rel, err := c.release(ctx, owner, repo, version) + if err != nil { + return "", err + } + cacheVersion := rel.TagName + if cacheVersion == "" { + cacheVersion = version + } + targetDir := filepath.Join(baseDir, repo, cacheVersion) + if lib, ok := FindSharedLibrary(targetDir, repo, c.Platform); ok { + return lib, nil + } + asset, err := SelectAsset(repo, rel.Assets, c.Platform) + if err != nil { + return "", err + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return "", err + } + downloadPath := filepath.Join(targetDir, asset.Name) + if err := c.download(ctx, asset.BrowserDownloadURL, downloadPath); err != nil { + return "", err + } + if isArchive(downloadPath) { + if err := extract(downloadPath, targetDir); err != nil { + return "", err + } + } + if lib, ok := FindSharedLibrary(targetDir, repo, c.Platform); ok { + return lib, nil + } + if strings.HasSuffix(strings.ToLower(downloadPath), c.Platform.SharedLibraryExt()) { + return downloadPath, nil + } + return "", fmt.Errorf("no shared library found in %s", targetDir) +} + +func (c Client) release(ctx context.Context, owner, repo, version string) (release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + if version != "latest" { + url = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, version) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return release{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + res, err := c.HTTP.Do(req) + if err != nil { + return release{}, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(res.Body, 512)) + return release{}, fmt.Errorf("github release request failed: %s %s", res.Status, strings.TrimSpace(string(body))) + } + var rel release + if err := json.NewDecoder(res.Body).Decode(&rel); err != nil { + return release{}, err + } + return rel, nil +} + +func (c Client) download(ctx context.Context, url, path string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + res, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("download failed: %s", res.Status) + } + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, res.Body) + return err +} + +func SelectAsset(repo string, assets []Asset, platform Platform) (Asset, error) { + type scored struct { + asset Asset + score int + } + var matches []scored + for _, asset := range assets { + name := strings.ToLower(asset.Name) + if strings.Contains(name, "sha256") || strings.Contains(name, "checksum") { + continue + } + score := 0 + if containsAny(name, platform.OSTokens()) { + score += 10 + } + if containsAny(name, platform.ArchTokens()) { + score += 10 + } + if strings.Contains(name, repo) || strings.Contains(name, strings.TrimPrefix(repo, "sqlite-")) { + score += 2 + } + if strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz") { + score++ + } + if strings.HasSuffix(name, platform.SharedLibraryExt()) { + score += 3 + } + if score >= 20 { + matches = append(matches, scored{asset: asset, score: score}) + } + } + if len(matches) == 0 { + return Asset{}, fmt.Errorf("no %s asset for %s/%s", repo, platform.OS, platform.Arch) + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].score == matches[j].score { + return matches[i].asset.Name < matches[j].asset.Name + } + return matches[i].score > matches[j].score + }) + return matches[0].asset, nil +} + +func FindSharedLibrary(root, repo string, platform Platform) (string, bool) { + ext := platform.SharedLibraryExt() + key := strings.TrimPrefix(repo, "sqlite-") + var found string + filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || found != "" { + return nil + } + name := strings.ToLower(d.Name()) + if strings.HasSuffix(name, ext) && (strings.Contains(name, key) || strings.Contains(name, repo)) { + found = path + } + return nil + }) + return found, found != "" +} + +func containsAny(s string, tokens []string) bool { + for _, token := range tokens { + if containsToken(s, token) { + return true + } + } + return false +} + +func containsToken(s, token string) bool { + start := 0 + for { + i := strings.Index(s[start:], token) + if i < 0 { + return false + } + i += start + before := i == 0 || !isTokenChar(rune(s[i-1])) + afterIndex := i + len(token) + after := afterIndex == len(s) || !isTokenChar(rune(s[afterIndex])) + if before && after { + return true + } + start = i + 1 + } +} + +func isTokenChar(r rune) bool { + return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' +} + +func isArchive(path string) bool { + lower := strings.ToLower(path) + return strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") +} + +func extract(path, targetDir string) error { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".zip") { + return extractZip(path, targetDir) + } + return extractTarGz(path, targetDir) +} + +func extractZip(path, targetDir string) error { + zr, err := zip.OpenReader(path) + if err != nil { + return err + } + defer zr.Close() + for _, f := range zr.File { + outPath, ok := safeArchivePath(targetDir, f.Name) + if !ok { + return fmt.Errorf("unsafe archive path %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(outPath, 0755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + in, err := f.Open() + if err != nil { + return err + } + out, err := os.Create(outPath) + if err != nil { + in.Close() + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + in.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + _ = os.Chmod(outPath, f.FileInfo().Mode()) + } + return nil +} + +func extractTarGz(path, targetDir string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if errorsIsEOF(err) { + return nil + } + if err != nil { + return err + } + outPath, ok := safeArchivePath(targetDir, h.Name) + if !ok { + return fmt.Errorf("unsafe archive path %s", h.Name) + } + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(outPath, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return err + } + out, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)) + if err != nil { + return err + } + _, copyErr := io.Copy(out, tr) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + } +} + +func safeArchivePath(targetDir, name string) (string, bool) { + cleanTarget := filepath.Clean(targetDir) + outPath := filepath.Join(cleanTarget, name) + cleanOut := filepath.Clean(outPath) + if cleanOut == cleanTarget { + return cleanOut, true + } + return cleanOut, strings.HasPrefix(cleanOut, cleanTarget+string(os.PathSeparator)) +} + +func errorsIsEOF(err error) bool { + return err == io.EOF +} diff --git a/cli/internal/download/download_test.go b/cli/internal/download/download_test.go new file mode 100644 index 0000000..14dfa3b --- /dev/null +++ b/cli/internal/download/download_test.go @@ -0,0 +1,106 @@ +package download + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func TestPlatformTokens(t *testing.T) { + p := Platform{OS: "darwin", Arch: "arm64"} + if p.SharedLibraryExt() != ".dylib" { + t.Fatalf("ext = %q", p.SharedLibraryExt()) + } + if !containsAny("sqlite-memory-macos-aarch64.tar.gz", p.OSTokens()) { + t.Fatal("macOS token not detected") + } + if !containsAny("sqlite-memory-macos-aarch64.tar.gz", p.ArchTokens()) { + t.Fatal("arm token not detected") + } +} + +func TestSelectAsset(t *testing.T) { + asset, err := SelectAsset("sqlite-memory", []Asset{ + {Name: "sqlite-memory-linux-x86_64.tar.gz"}, + {Name: "sqlite-memory-darwin-arm64.tar.gz"}, + {Name: "checksums.txt"}, + }, Platform{OS: "darwin", Arch: "arm64"}) + if err != nil { + t.Fatal(err) + } + if asset.Name != "sqlite-memory-darwin-arm64.tar.gz" { + t.Fatalf("asset = %q", asset.Name) + } +} + +func TestSelectAssetWindowsDoesNotMatchDarwin(t *testing.T) { + asset, err := SelectAsset("sqlite-memory", []Asset{ + {Name: "sqlite-memory-darwin-x86_64.tar.gz"}, + {Name: "sqlite-memory-windows-x86_64.tar.gz"}, + }, Platform{OS: "windows", Arch: "amd64"}) + if err != nil { + t.Fatal(err) + } + if asset.Name != "sqlite-memory-windows-x86_64.tar.gz" { + t.Fatalf("asset = %q", asset.Name) + } +} + +func TestFindSharedLibrary(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "libmemory.dylib") + if err := os.WriteFile(path, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + got, ok := FindSharedLibrary(dir, "sqlite-memory", Platform{OS: "darwin", Arch: "arm64"}) + if !ok || got != path { + t.Fatalf("lib = %q %v", got, ok) + } +} + +func TestExtractTarGzAllowsRootDirectoryEntry(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "archive.tar.gz") + if err := writeTarGz(archivePath, []tar.Header{ + {Name: "./", Typeflag: tar.TypeDir, Mode: 0755}, + {Name: "libmemory.dylib", Typeflag: tar.TypeReg, Mode: 0644, Size: 1}, + }, []byte("x")); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "target") + if err := os.MkdirAll(target, 0755); err != nil { + t.Fatal(err) + } + if err := extractTarGz(archivePath, target); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(target, "libmemory.dylib")); err != nil { + t.Fatal(err) + } +} + +func writeTarGz(path string, headers []tar.Header, fileData []byte) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + for _, header := range headers { + h := header + if err := tw.WriteHeader(&h); err != nil { + return err + } + if h.Typeflag == tar.TypeReg { + if _, err := tw.Write(fileData); err != nil { + return err + } + } + } + return nil +} diff --git a/cli/internal/download/platform.go b/cli/internal/download/platform.go new file mode 100644 index 0000000..d98a503 --- /dev/null +++ b/cli/internal/download/platform.go @@ -0,0 +1,52 @@ +package download + +import ( + "runtime" + "strings" +) + +type Platform struct { + OS string + Arch string +} + +func CurrentPlatform() Platform { + return Platform{OS: runtime.GOOS, Arch: runtime.GOARCH} +} + +func (p Platform) OSTokens() []string { + switch p.OS { + case "darwin": + return []string{"darwin", "macos", "mac", "osx"} + case "windows": + return []string{"windows", "win"} + case "linux": + return []string{"linux"} + default: + return []string{strings.ToLower(p.OS)} + } +} + +func (p Platform) ArchTokens() []string { + switch p.Arch { + case "amd64": + return []string{"amd64", "x86_64", "x64", "universal"} + case "arm64": + return []string{"arm64", "aarch64", "universal"} + case "386": + return []string{"386", "i386", "x86"} + default: + return []string{strings.ToLower(p.Arch)} + } +} + +func (p Platform) SharedLibraryExt() string { + switch p.OS { + case "darwin": + return ".dylib" + case "windows": + return ".dll" + default: + return ".so" + } +} diff --git a/cli/internal/mcp/mcp.go b/cli/internal/mcp/mcp.go new file mode 100644 index 0000000..6761ad6 --- /dev/null +++ b/cli/internal/mcp/mcp.go @@ -0,0 +1,297 @@ +package mcp + +import ( + "bufio" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/sqliteai/sqlite-memory/cli/internal/memory" +) + +type Server struct { + DB *sql.DB +} + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` +} + +func ToolNames() []string { + return []string{ + "memory_search", + "memory_add_file", + "memory_add_content", + "memory_add_directory", + "memory_add_text", + "memory_clear", + "memory_delete", + "memory_delete_context", + "memory_reindex", + "memory_status", + } +} + +func (s Server) ServeStdio(ctx context.Context, in io.Reader, out io.Writer) error { + reader := bufio.NewReader(in) + for { + msg, err := readFramedMessage(reader) + if err == io.EOF { + return nil + } + if err != nil { + return writeFramedMessage(out, response{JSONRPC: "2.0", Error: errObj(-32700, err.Error())}) + } + var req request + if err := json.Unmarshal(msg, &req); err != nil { + if err := writeFramedMessage(out, response{JSONRPC: "2.0", Error: errObj(-32700, err.Error())}); err != nil { + return err + } + continue + } + res, ok := s.Handle(ctx, req) + if !ok { + continue + } + if err := writeFramedMessage(out, res); err != nil { + return err + } + } +} + +func (s Server) ServeHTTP(ctx context.Context, addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, response{JSONRPC: "2.0", Error: errObj(-32700, err.Error())}) + return + } + res, ok := s.Handle(r.Context(), req) + if !ok { + w.WriteHeader(http.StatusNoContent) + return + } + writeJSON(w, res) + }) + server := &http.Server{Addr: addr, Handler: mux} + go func() { + <-ctx.Done() + _ = server.Shutdown(context.Background()) + }() + return server.ListenAndServe() +} + +func (s Server) Handle(ctx context.Context, req request) (response, bool) { + if req.ID == nil { + return response{}, false + } + switch req.Method { + case "initialize": + return ok(req.ID, map[string]any{ + "protocolVersion": "2024-11-05", + "serverInfo": map[string]any{"name": "sqlmem", "version": "0.1.0"}, + "capabilities": map[string]any{"tools": map[string]any{}}, + }), true + case "tools/list": + return ok(req.ID, map[string]any{"tools": tools()}), true + case "tools/call": + var params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return fail(req.ID, -32602, err.Error()), true + } + result, err := s.callTool(ctx, params.Name, params.Arguments) + if err != nil { + return fail(req.ID, -32000, err.Error()), true + } + return ok(req.ID, map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}), true + default: + return fail(req.ID, -32601, "method not found"), true + } +} + +func tools() []map[string]any { + return []map[string]any{ + tool("memory_search", map[string]any{ + "query": stringSchema("Search query"), + "limit": intSchema("Maximum number of results"), + }, []string{"query"}), + tool("memory_add_file", map[string]any{ + "path": stringSchema("File path"), + "context": stringSchema("Context label"), + }, []string{"path"}), + tool("memory_add_content", map[string]any{ + "path": stringSchema("File name or path"), + "content": stringSchema("File content"), + "context": stringSchema("Context label"), + }, []string{"path", "content"}), + tool("memory_add_directory", map[string]any{ + "path": stringSchema("Directory path"), + "context": stringSchema("Context label"), + }, []string{"path"}), + tool("memory_add_text", map[string]any{ + "text": stringSchema("Text content"), + "context": stringSchema("Context label"), + }, []string{"text"}), + tool("memory_clear", map[string]any{}, nil), + tool("memory_delete", map[string]any{ + "hash": stringSchema("Content hash"), + }, []string{"hash"}), + tool("memory_delete_context", map[string]any{ + "context": stringSchema("Context label"), + }, []string{"context"}), + tool("memory_reindex", map[string]any{}, nil), + tool("memory_status", map[string]any{}, nil), + } +} + +func tool(name string, properties map[string]any, required []string) map[string]any { + schema := map[string]any{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + return map[string]any{ + "name": name, + "description": name, + "inputSchema": schema, + } +} + +func stringSchema(description string) map[string]string { + return map[string]string{"type": "string", "description": description} +} + +func intSchema(description string) map[string]string { + return map[string]string{"type": "integer", "description": description} +} + +func (s Server) callTool(ctx context.Context, name string, args map[string]any) (string, error) { + switch name { + case "memory_search": + results, err := memory.Search(ctx, s.DB, strArg(args, "query"), intArg(args, "limit")) + return memory.ResultsJSON(results), err + case "memory_add_file": + return "ok", memory.AddFile(ctx, s.DB, strArg(args, "path"), strArg(args, "context")) + case "memory_add_content": + return "ok", memory.AddContent(ctx, s.DB, strArg(args, "path"), strArg(args, "content"), strArg(args, "context")) + case "memory_add_directory": + return "ok", memory.AddDirectory(ctx, s.DB, strArg(args, "path"), strArg(args, "context")) + case "memory_add_text": + return "ok", memory.AddText(ctx, s.DB, strArg(args, "text"), strArg(args, "context")) + case "memory_clear": + return "ok", memory.Clear(ctx, s.DB) + case "memory_delete": + return "ok", memory.Delete(ctx, s.DB, strArg(args, "hash")) + case "memory_delete_context": + return "ok", memory.DeleteContext(ctx, s.DB, strArg(args, "context")) + case "memory_reindex": + return "ok", memory.Reindex(ctx, s.DB) + case "memory_status": + status, err := memory.Status(ctx, s.DB) + data, _ := json.MarshalIndent(status, "", " ") + return string(data), err + default: + return "", fmt.Errorf("unknown tool %s", name) + } +} + +func ok(id any, result any) response { + return response{JSONRPC: "2.0", ID: id, Result: result} +} + +func fail(id any, code int, msg string) response { + return response{JSONRPC: "2.0", ID: id, Error: errObj(code, msg)} +} + +func errObj(code int, msg string) map[string]any { + return map[string]any{"code": code, "message": msg} +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func readFramedMessage(r *bufio.Reader) ([]byte, error) { + length := -1 + for { + line, err := r.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + key, value, ok := strings.Cut(line, ":") + if !ok || !strings.EqualFold(strings.TrimSpace(key), "Content-Length") { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n < 0 { + return nil, fmt.Errorf("invalid Content-Length") + } + length = n + } + if length < 0 { + return nil, fmt.Errorf("missing Content-Length") + } + msg := make([]byte, length) + _, err := io.ReadFull(r, msg) + return msg, err +} + +func writeFramedMessage(w io.Writer, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data)); err != nil { + return err + } + _, err = w.Write(data) + return err +} + +func strArg(args map[string]any, key string) string { + if v, ok := args[key].(string); ok { + return v + } + return "" +} + +func intArg(args map[string]any, key string) int { + switch v := args[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + n, _ := strconv.Atoi(v) + return n + default: + return 0 + } +} diff --git a/cli/internal/mcp/mcp_test.go b/cli/internal/mcp/mcp_test.go new file mode 100644 index 0000000..47a7833 --- /dev/null +++ b/cli/internal/mcp/mcp_test.go @@ -0,0 +1,97 @@ +package mcp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" +) + +func TestToolNames(t *testing.T) { + names := ToolNames() + want := map[string]bool{ + "memory_search": true, + "memory_add_file": true, + "memory_add_content": true, + "memory_add_directory": true, + "memory_add_text": true, + "memory_clear": true, + "memory_delete": true, + "memory_delete_context": true, + "memory_reindex": true, + "memory_status": true, + } + for _, name := range names { + delete(want, name) + } + if len(want) != 0 { + t.Fatalf("missing tools: %#v", want) + } +} + +func TestToolsListMapping(t *testing.T) { + res, ok := (Server{}).Handle(context.Background(), request{JSONRPC: "2.0", ID: 1, Method: "tools/list"}) + if !ok { + t.Fatal("expected response") + } + if res.Error != nil { + t.Fatalf("unexpected error: %#v", res.Error) + } + result := res.Result.(map[string]any) + tools := result["tools"].([]map[string]any) + if len(tools) != len(ToolNames()) { + t.Fatalf("tool count = %d", len(tools)) + } + for _, tool := range tools { + if _, ok := tool["inputSchema"].(map[string]any); !ok { + t.Fatalf("tool lacks inputSchema: %#v", tool) + } + } +} + +func TestInitializeAdvertisesToolsCapability(t *testing.T) { + res, ok := (Server{}).Handle(context.Background(), request{JSONRPC: "2.0", ID: 1, Method: "initialize"}) + if !ok { + t.Fatal("expected response") + } + result := res.Result.(map[string]any) + capabilities := result["capabilities"].(map[string]any) + if _, ok := capabilities["tools"].(map[string]any); !ok { + t.Fatalf("tools capability missing: %#v", capabilities) + } +} + +func TestHandleNotificationWithoutResponse(t *testing.T) { + _, ok := (Server{}).Handle(context.Background(), request{JSONRPC: "2.0", Method: "notifications/initialized"}) + if ok { + t.Fatal("notification returned a response") + } +} + +func TestServeStdioUsesContentLengthFraming(t *testing.T) { + req := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) + in := strings.NewReader(fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(req), req)) + var out bytes.Buffer + if err := (Server{}).ServeStdio(context.Background(), in, &out); err != nil { + t.Fatal(err) + } + msg, err := readFramedMessage(bufioReader(&out)) + if err != nil { + t.Fatal(err) + } + var res response + if err := json.Unmarshal(msg, &res); err != nil { + t.Fatal(err) + } + if res.ID != float64(1) || res.Error != nil { + t.Fatalf("response = %#v", res) + } +} + +func bufioReader(r io.Reader) *bufio.Reader { + return bufio.NewReader(r) +} diff --git a/cli/internal/memory/memory.go b/cli/internal/memory/memory.go new file mode 100644 index 0000000..5583ee5 --- /dev/null +++ b/cli/internal/memory/memory.go @@ -0,0 +1,223 @@ +package memory + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "path/filepath" + + "github.com/sqliteai/sqlite-memory/cli/internal/config" +) + +const defaultRemoteModel = "text-embedding-3-small" + +type ModelOptions struct { + Provider string + Model string + APIKey string +} + +type SearchResult struct { + Hash string `json:"hash"` + Seq int `json:"seq"` + Ranking float64 `json:"ranking"` + Path string `json:"path"` + Snippet string `json:"snippet"` +} + +func ResolveModel(cfg config.Config, opts ModelOptions) ModelOptions { + apiKey := config.ResolveAPIKey(cfg, opts.APIKey) + model := opts.Model + if model == "" { + model = cfg.Embedding.Model + } + provider := opts.Provider + if provider == "" { + provider = cfg.Embedding.Provider + } + if apiKey != "" { + if provider == "" || provider == "local" { + provider = "openai" + } + if model == "" { + model = defaultRemoteModel + } + } else { + provider = "local" + } + return ModelOptions{Provider: provider, Model: model, APIKey: apiKey} +} + +func Configure(ctx context.Context, db *sql.DB, cfg config.Config, opts ModelOptions) error { + resolved := ResolveModel(cfg, opts) + for key, value := range optionMap(cfg.Options) { + if _, err := db.ExecContext(ctx, "SELECT memory_set_option(?, ?)", key, value); err != nil { + return err + } + } + if resolved.APIKey != "" { + if _, err := db.ExecContext(ctx, "SELECT memory_set_apikey(?)", resolved.APIKey); err != nil { + return err + } + } + if resolved.Model == "" { + return nil + } + _, err := db.ExecContext(ctx, "SELECT memory_set_model(?, ?)", resolved.Provider, resolved.Model) + return err +} + +func AddFile(ctx context.Context, db *sql.DB, path, contextLabel string) error { + if contextLabel == "" { + _, err := db.ExecContext(ctx, "SELECT memory_add_file(?)", path) + return err + } + _, err := db.ExecContext(ctx, "SELECT memory_add_file(?, ?)", path, contextLabel) + return err +} + +func AddContent(ctx context.Context, db *sql.DB, path, content, contextLabel string) error { + if contextLabel == "" { + _, err := db.ExecContext(ctx, "SELECT memory_add_content(?, ?)", path, content) + return err + } + _, err := db.ExecContext(ctx, "SELECT memory_add_content(?, ?, ?)", path, content, contextLabel) + return err +} + +func AddDirectory(ctx context.Context, db *sql.DB, path, contextLabel string) error { + if contextLabel == "" { + _, err := db.ExecContext(ctx, "SELECT memory_add_directory(?)", path) + return err + } + _, err := db.ExecContext(ctx, "SELECT memory_add_directory(?, ?)", path, contextLabel) + return err +} + +func AddText(ctx context.Context, db *sql.DB, text, contextLabel string) error { + if contextLabel == "" { + _, err := db.ExecContext(ctx, "SELECT memory_add_text(?)", text) + return err + } + _, err := db.ExecContext(ctx, "SELECT memory_add_text(?, ?)", text, contextLabel) + return err +} + +func Search(ctx context.Context, db *sql.DB, query string, limit int) ([]SearchResult, error) { + if limit > 0 { + return scanSearch(db.QueryContext(ctx, "SELECT hash, seq, ranking, path, snippet FROM memory_search WHERE query = ? AND max_entries = ?", query, limit)) + } + return scanSearch(db.QueryContext(ctx, "SELECT hash, seq, ranking, path, snippet FROM memory_search WHERE query = ?", query)) +} + +func Clear(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT memory_clear()") + return err +} + +func Delete(ctx context.Context, db *sql.DB, hash string) error { + _, err := db.ExecContext(ctx, "SELECT memory_delete(?)", hash) + return err +} + +func DeletePath(ctx context.Context, db *sql.DB, path string) error { + for _, candidate := range pathCandidates(path) { + var deleted int + if err := db.QueryRowContext(ctx, "SELECT memory_delete_file(?)", candidate).Scan(&deleted); err != nil { + return err + } + if deleted > 0 { + return nil + } + } + return nil +} + +func pathCandidates(path string) []string { + candidates := []string{filepath.ToSlash(filepath.Clean(path))} + if filepath.IsAbs(path) { + candidates = append(candidates, filepath.ToSlash(filepath.Base(path))) + } + + out := candidates[:0] + seen := map[string]struct{}{} + for _, candidate := range candidates { + if candidate == "." || candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + out = append(out, candidate) + } + return out +} + +func DeleteContext(ctx context.Context, db *sql.DB, contextLabel string) error { + _, err := db.ExecContext(ctx, "SELECT memory_delete_context(?)", contextLabel) + return err +} + +func Reindex(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT memory_reindex()") + return err +} + +func Status(ctx context.Context, db *sql.DB) (map[string]any, error) { + out := map[string]any{} + var memories, chunks int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbmem_content").Scan(&memories) + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbmem_vault").Scan(&chunks) + out["memories"] = memories + out["chunks"] = chunks + return out, nil +} + +func ResultsJSON(results []SearchResult) string { + data, _ := json.MarshalIndent(results, "", " ") + return string(data) +} + +func scanSearch(rows *sql.Rows, err error) ([]SearchResult, error) { + if err != nil { + return nil, err + } + defer rows.Close() + var results []SearchResult + for rows.Next() { + var r SearchResult + if err := rows.Scan(&r.Hash, &r.Seq, &r.Ranking, &r.Path, &r.Snippet); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() +} + +func optionMap(opts config.Options) map[string]any { + return map[string]any{ + "max_tokens": opts.MaxTokens, + "overlay_tokens": opts.OverlayTokens, + "max_results": opts.MaxResults, + "min_score": opts.MinScore, + "vector_weight": opts.VectorWeight, + "text_weight": opts.TextWeight, + "search_oversample": opts.SearchOversample, + "extensions": opts.Extensions, + "embedding_cache": boolInt(opts.EmbeddingCache), + "cache_max_entries": opts.CacheMaxEntries, + } +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func FormatResult(r SearchResult) string { + return fmt.Sprintf("%.3f %s\n%s", r.Ranking, r.Path, r.Snippet) +} diff --git a/cli/internal/memory/memory_test.go b/cli/internal/memory/memory_test.go new file mode 100644 index 0000000..c9e75c0 --- /dev/null +++ b/cli/internal/memory/memory_test.go @@ -0,0 +1,44 @@ +package memory + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/sqliteai/sqlite-memory/cli/internal/config" +) + +func TestResolveModelLocalWithoutAPIKey(t *testing.T) { + cfg := config.Default() + cfg.Embedding.Model = "/models/local.gguf" + got := ResolveModel(cfg, ModelOptions{}) + if got.Provider != "local" || got.Model != "/models/local.gguf" { + t.Fatalf("model = %#v", got) + } +} + +func TestResolveModelRemoteWithAPIKey(t *testing.T) { + cfg := config.Default() + got := ResolveModel(cfg, ModelOptions{APIKey: "key"}) + if got.Provider != "openai" { + t.Fatalf("provider = %q", got.Provider) + } + if got.Model != defaultRemoteModel { + t.Fatalf("model = %q", got.Model) + } +} + +func TestPathCandidatesAbsolutePathIncludesStoredBasename(t *testing.T) { + path := filepath.Join(t.TempDir(), "docs", "readme.md") + want := []string{filepath.ToSlash(filepath.Clean(path)), "readme.md"} + if got := pathCandidates(path); !reflect.DeepEqual(got, want) { + t.Fatalf("pathCandidates() = %#v, want %#v", got, want) + } +} + +func TestPathCandidatesRelativePathPreservesDirectory(t *testing.T) { + want := []string{"docs/readme.md"} + if got := pathCandidates(filepath.Join("docs", "readme.md")); !reflect.DeepEqual(got, want) { + t.Fatalf("pathCandidates() = %#v, want %#v", got, want) + } +} diff --git a/cli/internal/output/spinner.go b/cli/internal/output/spinner.go new file mode 100644 index 0000000..80b597f --- /dev/null +++ b/cli/internal/output/spinner.go @@ -0,0 +1,66 @@ +package output + +import ( + "fmt" + "io" + "os" + "sync" + "time" +) + +type Spinner struct { + w io.Writer + label string + done chan struct{} + stopped chan struct{} + once sync.Once + active bool +} + +func NewSpinner(label string) *Spinner { + return &Spinner{ + w: os.Stderr, + label: label, + done: make(chan struct{}), + stopped: make(chan struct{}), + active: isTerminal(os.Stderr), + } +} + +func (s *Spinner) Start() { + if !s.active { + return + } + go func() { + defer close(s.stopped) + frames := []rune{'-', '\\', '|', '/'} + t := time.NewTicker(120 * time.Millisecond) + defer t.Stop() + i := 0 + for { + select { + case <-s.done: + fmt.Fprint(s.w, "\r\033[K") + return + case <-t.C: + fmt.Fprintf(s.w, "\r%c %s", frames[i%len(frames)], s.label) + i++ + } + } + }() +} + +func (s *Spinner) Stop() { + s.once.Do(func() { + if !s.active { + return + } + close(s.done) + <-s.stopped + }) +} + +func isTerminal(f *os.File) bool { + info, err := f.Stat() + return err == nil && (info.Mode()&os.ModeCharDevice) != 0 +} diff --git a/cli/internal/pdf/pdf.go b/cli/internal/pdf/pdf.go new file mode 100644 index 0000000..047b7dd --- /dev/null +++ b/cli/internal/pdf/pdf.go @@ -0,0 +1,150 @@ +package pdf + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" +) + +type Metadata struct { + Path string `json:"path"` + MTime int64 `json:"mtime"` + Size int64 `json:"size"` + Hash string `json:"hash"` +} + +type Result struct { + MarkdownPath string + IndexPath string + Skipped bool + Metadata Metadata +} + +type Converter interface { + Convert(ctx context.Context, pdfPath string) (string, error) +} + +type GLMOCRConverter struct{} + +func (GLMOCRConverter) Convert(ctx context.Context, pdfPath string) (string, error) { + cmd := exec.CommandContext(ctx, "glm-ocr", pdfPath) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("convert PDF with glm-ocr: %w", err) + } + return string(out), nil +} + +type Cache struct { + Dir string + Force bool + Converter Converter +} + +func (c Cache) Process(ctx context.Context, path string) (Result, error) { + meta, err := ReadMetadata(path) + if err != nil { + return Result{}, err + } + dir := filepath.Join(c.Dir, meta.Hash) + sourcePath := filepath.Join(dir, "source.json") + contentPath := filepath.Join(dir, "content.md") + indexPath := IndexPathForSource(c.Dir, meta.Path) + if !c.Force && unchanged(sourcePath, contentPath, indexPath, meta) { + return Result{MarkdownPath: contentPath, IndexPath: indexPath, Skipped: true, Metadata: meta}, nil + } + converter := c.Converter + if converter == nil { + converter = GLMOCRConverter{} + } + markdown, err := converter.Convert(ctx, path) + if err != nil { + return Result{}, err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return Result{}, err + } + if err := os.WriteFile(contentPath, []byte(markdown), 0644); err != nil { + return Result{}, err + } + if err := os.MkdirAll(filepath.Dir(indexPath), 0755); err != nil { + return Result{}, err + } + if err := os.WriteFile(indexPath, []byte(markdown), 0644); err != nil { + return Result{}, err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return Result{}, err + } + data = append(data, '\n') + if err := os.WriteFile(sourcePath, data, 0644); err != nil { + return Result{}, err + } + return Result{MarkdownPath: contentPath, IndexPath: indexPath, Metadata: meta}, nil +} + +func ReadMetadata(path string) (Metadata, error) { + info, err := os.Stat(path) + if err != nil { + return Metadata{}, err + } + f, err := os.Open(path) + if err != nil { + return Metadata{}, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return Metadata{}, err + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + return Metadata{ + Path: abs, + MTime: info.ModTime().UnixNano(), + Size: info.Size(), + Hash: hex.EncodeToString(h.Sum(nil)), + }, nil +} + +func unchanged(sourcePath, contentPath, indexPath string, meta Metadata) bool { + if _, err := os.Stat(contentPath); err != nil { + return false + } + if _, err := os.Stat(indexPath); err != nil { + return false + } + data, err := os.ReadFile(sourcePath) + if err != nil { + return false + } + var old Metadata + if err := json.Unmarshal(data, &old); err != nil { + return false + } + return old.Hash == meta.Hash && old.Size == meta.Size && old.MTime == meta.MTime +} + +func IndexPathForSource(cacheDir string, path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + sum := sha256.Sum256([]byte(abs)) + name := hex.EncodeToString(sum[:]) + ".md" + return filepath.Join(cacheDir, "index", name) +} + +func Touch(path string, t time.Time) error { + return os.Chtimes(path, t, t) +} diff --git a/cli/internal/pdf/pdf_test.go b/cli/internal/pdf/pdf_test.go new file mode 100644 index 0000000..b0df893 --- /dev/null +++ b/cli/internal/pdf/pdf_test.go @@ -0,0 +1,56 @@ +package pdf + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +type fakeConverter struct { + calls int +} + +func (f *fakeConverter) Convert(ctx context.Context, pdfPath string) (string, error) { + f.calls++ + return "# converted\n", nil +} + +func TestPDFCacheSkipUnchanged(t *testing.T) { + dir := t.TempDir() + pdfPath := filepath.Join(dir, "doc.pdf") + if err := os.WriteFile(pdfPath, []byte("pdf bytes"), 0644); err != nil { + t.Fatal(err) + } + converter := &fakeConverter{} + cache := Cache{Dir: filepath.Join(dir, "cache"), Converter: converter} + first, err := cache.Process(context.Background(), pdfPath) + if err != nil { + t.Fatal(err) + } + if first.Skipped { + t.Fatal("first conversion skipped") + } + if first.IndexPath == "" { + t.Fatal("index path not set") + } + if first.IndexPath != IndexPathForSource(cache.Dir, pdfPath) { + t.Fatalf("index path mismatch: %q", first.IndexPath) + } + if _, err := os.Stat(first.IndexPath); err != nil { + t.Fatalf("index markdown not written: %v", err) + } + second, err := cache.Process(context.Background(), pdfPath) + if err != nil { + t.Fatal(err) + } + if !second.Skipped { + t.Fatal("unchanged PDF was not skipped") + } + if converter.calls != 1 { + t.Fatalf("converter calls = %d", converter.calls) + } + if second.IndexPath != first.IndexPath { + t.Fatalf("index path changed: %q != %q", second.IndexPath, first.IndexPath) + } +} diff --git a/cli/internal/sqlite/sqlite.go b/cli/internal/sqlite/sqlite.go new file mode 100644 index 0000000..324c585 --- /dev/null +++ b/cli/internal/sqlite/sqlite.go @@ -0,0 +1,186 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync/atomic" + + sqlite3 "github.com/mattn/go-sqlite3" + "github.com/sqliteai/sqlite-memory/cli/internal/config" + "github.com/sqliteai/sqlite-memory/cli/internal/download" +) + +var driverSeq atomic.Uint64 + +type ExtensionPaths struct { + Vector string + Memory string + Sync string +} + +type OpenOptions struct { + Config config.Config + ConfigPath string + ExtensionsDir string + SkipLoad bool +} + +func Open(ctx context.Context, opts OpenOptions) (*sql.DB, error) { + dbPath := config.DatabasePath(opts.ConfigPath, opts.Config) + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + return nil, err + } + driverName := "sqlite3" + if !opts.SkipLoad { + paths, err := ResolveExtensions(opts.Config, opts.ExtensionsDir) + if err != nil { + return nil, err + } + driverName = registerExtensionDriver(paths) + } + db, err := sql.Open(driverName, dbPath) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func ResolveExtensions(cfg config.Config, dirOverride string) (ExtensionPaths, error) { + base := config.ResolveExtensionsDir(cfg, dirOverride) + vector, err := resolveOne(base, download.RepoVector, "vector", cfg) + if err != nil { + return ExtensionPaths{}, err + } + memory, err := resolveOne(base, download.RepoMemory, "memory", cfg) + if err != nil { + return ExtensionPaths{}, err + } + var syncPath string + if cfg.Extensions["sync"] != "" { + syncPath, err = resolveOne(base, download.RepoSync, "sync", cfg) + if err != nil { + return ExtensionPaths{}, err + } + } + return ExtensionPaths{Vector: vector, Memory: memory, Sync: syncPath}, nil +} + +func LoadExtensions(ctx context.Context, db *sql.DB, paths ExtensionPaths) error { + return fmt.Errorf("extensions must be loaded while opening the database") +} + +func registerExtensionDriver(paths ExtensionPaths) string { + name := fmt.Sprintf("sqlite3_sqlmem_%d", driverSeq.Add(1)) + sql.Register(name, &sqlite3.SQLiteDriver{Extensions: extensionOrder(paths)}) + return name +} + +func extensionOrder(paths ExtensionPaths) []string { + order := []string{paths.Vector, paths.Memory} + if paths.Sync != "" { + order = append(order, paths.Sync) + } + return order +} + +func resolveOne(base, repo, key string, cfg config.Config) (string, error) { + if v := cfg.Extensions[key]; v != "" && v != "latest" { + if filepath.IsAbs(v) || fileExists(v) { + return v, nil + } + } + version := cfg.ExtensionVersions[key] + if version == "" { + version = cfg.Extensions[key] + } + if version == "" { + version = "latest" + } + lib, ok := download.FindSharedLibrary(filepath.Join(base, repo, version), repo, download.CurrentPlatform()) + if !ok && version == "latest" { + lib, ok = findAnyInstalled(base, repo) + } + if !ok { + return "", fmt.Errorf("%s extension not installed. Run `sqlmem extensions install %s`.", key, key) + } + return lib, nil +} + +func findAnyInstalled(base, repo string) (string, bool) { + root := filepath.Join(base, repo) + entries, err := os.ReadDir(root) + if err != nil { + return "", false + } + sort.Slice(entries, func(i, j int) bool { + return compareVersions(entries[i].Name(), entries[j].Name()) > 0 + }) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if lib, ok := download.FindSharedLibrary(filepath.Join(root, entry.Name()), repo, download.CurrentPlatform()); ok { + return lib, true + } + } + return "", false +} + +func compareVersions(a, b string) int { + an := versionNumbers(a) + bn := versionNumbers(b) + if len(an) == 0 || len(bn) == 0 { + return strings.Compare(a, b) + } + for i := 0; i < len(an) || i < len(bn); i++ { + av, bv := 0, 0 + if i < len(an) { + av = an[i] + } + if i < len(bn) { + bv = bn[i] + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + return strings.Compare(a, b) +} + +func versionNumbers(version string) []int { + version = strings.TrimLeft(version, "vV") + parts := strings.FieldsFunc(version, func(r rune) bool { + return r < '0' || r > '9' + }) + nums := make([]int, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + n, err := strconv.Atoi(part) + if err != nil { + return nil + } + nums = append(nums, n) + } + return nums +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cli/internal/sqlite/sqlite_test.go b/cli/internal/sqlite/sqlite_test.go new file mode 100644 index 0000000..32de87f --- /dev/null +++ b/cli/internal/sqlite/sqlite_test.go @@ -0,0 +1,59 @@ +package sqlite + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sqliteai/sqlite-memory/cli/internal/config" + "github.com/sqliteai/sqlite-memory/cli/internal/download" +) + +func TestResolveExtensionsLatestFindsInstalledTag(t *testing.T) { + if download.CurrentPlatform().SharedLibraryExt() != ".dylib" && download.CurrentPlatform().SharedLibraryExt() != ".so" && download.CurrentPlatform().SharedLibraryExt() != ".dll" { + t.Skip("unsupported platform") + } + dir := t.TempDir() + ext := download.CurrentPlatform().SharedLibraryExt() + vector := filepath.Join(dir, download.RepoVector, "v1.2.3", "vector"+ext) + memory := filepath.Join(dir, download.RepoMemory, "v1.2.3", "memory"+ext) + for _, path := range []string{vector, memory} { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + cfg := config.Default() + paths, err := ResolveExtensions(cfg, dir) + if err != nil { + t.Fatal(err) + } + if paths.Vector != vector || paths.Memory != memory { + t.Fatalf("paths = %#v", paths) + } +} + +func TestFindAnyInstalledPrefersNewestSemverTag(t *testing.T) { + dir := t.TempDir() + ext := download.CurrentPlatform().SharedLibraryExt() + oldPath := filepath.Join(dir, download.RepoMemory, "v1.9.0", "memory"+ext) + newPath := filepath.Join(dir, download.RepoMemory, "v1.10.0", "memory"+ext) + for _, path := range []string{oldPath, newPath} { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + got, ok := findAnyInstalled(dir, download.RepoMemory) + if !ok { + t.Fatal("no installed extension found") + } + if got != newPath { + t.Fatalf("installed extension = %q, want %q", got, newPath) + } +} diff --git a/cli/internal/watch/watch.go b/cli/internal/watch/watch.go new file mode 100644 index 0000000..c59e094 --- /dev/null +++ b/cli/internal/watch/watch.go @@ -0,0 +1,184 @@ +package watch + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +type Handler func(context.Context, string, bool) error + +type event struct { + Path string + Remove bool + Ordinal int +} + +type Debouncer struct { + Delay time.Duration + mu sync.Mutex + timer *time.Timer + next int + pending map[string]event +} + +func NewDebouncer(delay time.Duration) *Debouncer { + return &Debouncer{Delay: delay, pending: map[string]event{}} +} + +func (d *Debouncer) Trigger(path string, remove bool, fn func([]event)) { + d.mu.Lock() + d.next++ + d.pending[path] = event{Path: path, Remove: remove, Ordinal: d.next} + if d.timer != nil { + d.timer.Stop() + } + d.timer = time.AfterFunc(d.Delay, func() { + d.mu.Lock() + events := make([]event, 0, len(d.pending)) + for _, ev := range d.pending { + events = append(events, ev) + } + d.pending = map[string]event{} + d.mu.Unlock() + sort.Slice(events, func(i, j int) bool { + return events[i].Ordinal < events[j].Ordinal + }) + fn(events) + }) + d.mu.Unlock() +} + +func Run(ctx context.Context, sources []string, delay time.Duration, handler Handler) error { + w, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer w.Close() + filter, err := newSourceFilter(sources) + if err != nil { + return err + } + for _, source := range sources { + if err := addSource(w, source); err != nil { + return err + } + } + debounce := NewDebouncer(delay) + errs := make(chan error, 1) + reportErr := func(err error) { + if err == nil { + return + } + select { + case errs <- err: + default: + } + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errs: + return err + case err := <-w.Errors: + if err != nil { + return err + } + case ev := <-w.Events: + if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { + continue + } + if !filter.Allows(ev.Name) { + continue + } + path := ev.Name + remove := ev.Op&(fsnotify.Rename|fsnotify.Remove) != 0 + debounce.Trigger(path, remove, func(events []event) { + for _, ev := range events { + if err := handler(ctx, ev.Path, ev.Remove); err != nil { + reportErr(err) + continue + } + if !ev.Remove { + if info, err := os.Stat(ev.Path); err == nil && info.IsDir() { + reportErr(addSource(w, ev.Path)) + } + } + } + }) + } + } +} + +type sourceFilter struct { + files map[string]struct{} + dirs []string +} + +func newSourceFilter(sources []string) (sourceFilter, error) { + filter := sourceFilter{files: map[string]struct{}{}} + for _, source := range sources { + info, err := os.Stat(source) + if err != nil { + return sourceFilter{}, err + } + path, err := filepath.Abs(source) + if err != nil { + return sourceFilter{}, err + } + path = filepath.Clean(path) + if info.IsDir() { + filter.dirs = append(filter.dirs, path) + } else { + filter.files[path] = struct{}{} + } + } + return filter, nil +} + +func (f sourceFilter) Allows(path string) bool { + path, err := filepath.Abs(path) + if err != nil { + return false + } + path = filepath.Clean(path) + if _, ok := f.files[path]; ok { + return true + } + for _, dir := range f.dirs { + if path == dir { + return true + } + rel, err := filepath.Rel(dir, path) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel) { + return true + } + } + return false +} + +func addSource(w *fsnotify.Watcher, source string) error { + info, err := os.Stat(source) + if err != nil { + return err + } + if !info.IsDir() { + return w.Add(filepath.Dir(source)) + } + return filepath.WalkDir(source, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return w.Add(path) + } + return nil + }) +} diff --git a/cli/internal/watch/watch_test.go b/cli/internal/watch/watch_test.go new file mode 100644 index 0000000..d7ef415 --- /dev/null +++ b/cli/internal/watch/watch_test.go @@ -0,0 +1,102 @@ +package watch + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestDebouncer(t *testing.T) { + done := make(chan []event, 1) + d := NewDebouncer(20 * time.Millisecond) + d.Trigger("b.md", false, func(events []event) { done <- events }) + d.Trigger("a.md", true, func(events []event) { done <- events }) + select { + case events := <-done: + if len(events) != 2 { + t.Fatalf("events = %#v", events) + } + if events[0].Path != "b.md" || events[0].Remove { + t.Fatalf("first event = %#v", events[0]) + } + if events[1].Path != "a.md" || !events[1].Remove { + t.Fatalf("second event = %#v", events[1]) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("debouncer did not fire") + } +} + +func TestRunReturnsHandlerError(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + want := errors.New("handler failed") + done := make(chan error, 1) + go func() { + done <- Run(ctx, []string{dir}, 5*time.Millisecond, func(context.Context, string, bool) error { + return want + }) + }() + + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + deadline := time.After(2 * time.Second) + for { + select { + case err := <-done: + if !errors.Is(err, want) { + t.Fatalf("Run error = %v, want %v", err, want) + } + return + case <-ticker.C: + path := filepath.Join(dir, "file.md") + if err := os.WriteFile(path, []byte(time.Now().String()), 0644); err != nil { + t.Fatal(err) + } + case <-deadline: + cancel() + t.Fatal("Run did not return handler error") + } + } +} + +func TestSourceFilterAllowsOnlyConfiguredFile(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.md") + sibling := filepath.Join(dir, "sibling.md") + if err := os.WriteFile(source, []byte("source"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sibling, []byte("sibling"), 0644); err != nil { + t.Fatal(err) + } + filter, err := newSourceFilter([]string{source}) + if err != nil { + t.Fatal(err) + } + if !filter.Allows(source) { + t.Fatal("configured file was not allowed") + } + if filter.Allows(sibling) { + t.Fatal("sibling file was allowed") + } +} + +func TestSourceFilterAllowsDirectoryChildren(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "child.md") + if err := os.WriteFile(child, []byte("child"), 0644); err != nil { + t.Fatal(err) + } + filter, err := newSourceFilter([]string{dir}) + if err != nil { + t.Fatal(err) + } + if !filter.Allows(child) { + t.Fatal("directory child was not allowed") + } +} diff --git a/sessions/session-2026-02-10.md b/sessions/session-2026-02-10.md deleted file mode 100644 index b0f54bd..0000000 --- a/sessions/session-2026-02-10.md +++ /dev/null @@ -1,70 +0,0 @@ -# Session Summary - 2026-02-10 - -## Project: sqlite-memory - -SQLite extension for AI agent memory with semantic search, hybrid retrieval, and offline-first sync between agents. - ---- - -## Work Completed - -### 1. Documentation Updates - -Updated `README.md` and `API.md` to reflect changes to the `memory_search` virtual table: - -- **Renamed column**: `score` → `ranking` in all query examples and documentation -- **Documented columns**: `path`, `snippet`, `ranking` properly described -- **Preserved settings**: `min_score` setting name unchanged (configuration option, not column) - -### 2. GitHub Project Description - -Created project descriptions for GitHub: - -**Short (About field):** -> SQLite extension for AI agent memory with semantic search, hybrid retrieval, and offline-first sync between agents - -**Full description:** -> A SQLite extension that gives AI agents persistent, searchable memory. Features hybrid semantic search (vector similarity + FTS5), markdown-aware chunking, and local embedding via llama.cpp. Memory databases can be synced between agents using offline-first technology—each agent works independently and syncs when connected, making it ideal for distributed AI systems, edge deployments, and collaborative agent architectures. - ---- - -## Key Files Modified - -| File | Changes | -|------|---------| -| `README.md` | Updated `memory_search` examples to use `ranking` column | -| `API.md` | Updated column documentation and examples for `memory_search` | - ---- - -## memory_search Virtual Table Schema - -```sql -SELECT * FROM memory_search WHERE query = 'search text'; -``` - -| Column | Type | Description | -|--------|------|-------------| -| `query` | TEXT (HIDDEN) | Search query (required in WHERE clause) | -| `hash` | INTEGER | Content hash identifier | -| `path` | TEXT | Source file path or generated UUID | -| `context` | TEXT | Context label (NULL if not set) | -| `snippet` | TEXT | Text snippet from matching chunk | -| `ranking` | REAL | Combined similarity score (0.0 - 1.0) | - ---- - -## Previous Session Context - -This session continued from earlier work that included: -- Implementing memory deletion, timestamps, and statistics features -- Building Makefile with conditional llama.cpp/remote engine support -- Adding support for both local (llama.cpp) and remote (vector.space) embedding -- Renaming `max_items` to `max_results` throughout codebase -- Creating comprehensive README.md and API.md documentation - ---- - -## Version - -sqlite-memory v0.5.1 diff --git a/src/dbmem-embed.h b/src/dbmem-embed.h index 0ddc66d..540a0ed 100644 --- a/src/dbmem-embed.h +++ b/src/dbmem-embed.h @@ -17,12 +17,12 @@ typedef struct dbmem_remote_engine_t dbmem_remote_engine_t; // Embedding result structure (always one embedding per call) typedef struct { int n_tokens; // Number of tokens processed - int n_tokens_truncated; // Number of tokens truncated (0 if none) + bool truncated; // True when the input was truncated before embedding int n_embd; // Embedding dimension float *embedding; // Pointer to embedding (points to engine's buffer, do not free) } embedding_result_t; -dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path, char err_msg[DBMEM_ERRBUF_SIZE]); +dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path, int max_context_tokens, char err_msg[DBMEM_ERRBUF_SIZE]); int dbmem_local_compute_embedding (dbmem_local_engine_t *engine, const char *text, int text_len, embedding_result_t *result); bool dbmem_local_engine_warmup (dbmem_local_engine_t *engine); void dbmem_local_engine_free (dbmem_local_engine_t *engine); diff --git a/src/dbmem-lembed.c b/src/dbmem-lembed.c index ce0f0e1..2d6fbd0 100644 --- a/src/dbmem-lembed.c +++ b/src/dbmem-lembed.c @@ -13,6 +13,21 @@ #include #include +#define DBMEM_LOCAL_MIN_CONTEXT_TOKENS 128 +#define DBMEM_LOCAL_MAX_CONTEXT_TOKENS 8192 + +#if defined(_MSC_VER) +#define DBMEM_THREAD_LOCAL __declspec(thread) +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +#define DBMEM_THREAD_LOCAL _Thread_local +#else +#define DBMEM_THREAD_LOCAL __thread +#endif + +static DBMEM_THREAD_LOCAL bool dbmem_llama_diag_enabled = false; +static DBMEM_THREAD_LOCAL char dbmem_llama_diag[DBMEM_ERRBUF_SIZE]; +static DBMEM_THREAD_LOCAL size_t dbmem_llama_diag_len = 0; + struct dbmem_local_engine_t { dbmem_context *context; @@ -26,6 +41,7 @@ struct dbmem_local_engine_t { // Model info int n_embd; // Embedding dimension (e.g., 768 for nomic-embed) int n_ctx; // Maximum context length in tokens + int n_ubatch; // Maximum physical batch size for encoder input bool is_encoder_only; // True for BERT-style models, false for GPT-style // Settings @@ -33,7 +49,9 @@ struct dbmem_local_engine_t { // Reusable buffers (avoid repeated allocations) llama_token *tokens; // Pre-allocated buffer for tokenized input - int tokens_capacity; // Size of tokens buffer (equals n_ctx) + int tokens_capacity; // Size of tokens buffer, capped by n_ubatch + struct llama_batch batch; // Pre-allocated llama.cpp batch with sequence metadata + bool batch_initialized; // True when batch must be freed float *embedding; // Pre-allocated buffer for output embedding (n_embd floats) // Statistics @@ -75,75 +93,130 @@ static void dbmem_embedding_normalize (float *vec, int n) { } void dbmem_logger (enum ggml_log_level level, const char *text, void *user_data) { - dbmem_local_engine_t *engine = (dbmem_local_engine_t *)user_data; - //if (ai->db == NULL) return; - //if ((level == GGML_LOG_LEVEL_INFO) && (ai->options.log_info == false)) return; - - const char *type = NULL; - switch (level) { - case GGML_LOG_LEVEL_NONE: type = "NONE"; break; - case GGML_LOG_LEVEL_DEBUG: type = "DEBUG"; break; - case GGML_LOG_LEVEL_INFO: type = "INFO"; break; - case GGML_LOG_LEVEL_WARN: type = "WARNING"; break; - case GGML_LOG_LEVEL_ERROR: type = "ERROR"; break; - case GGML_LOG_LEVEL_CONT: type = NULL; break; + UNUSED_PARAM(user_data); + if (!dbmem_llama_diag_enabled || !text) return; + + if (level == GGML_LOG_LEVEL_WARN || level == GGML_LOG_LEVEL_ERROR || level == GGML_LOG_LEVEL_CONT) { + size_t remaining = sizeof(dbmem_llama_diag) - dbmem_llama_diag_len; + if (remaining > 1) { + int written = snprintf(dbmem_llama_diag + dbmem_llama_diag_len, remaining, "%s", text); + if (written > 0) { + size_t used = (size_t)written; + if (used >= remaining) { + dbmem_llama_diag_len = sizeof(dbmem_llama_diag) - 1; + } else { + dbmem_llama_diag_len += used; + } + } + } } - - // DEBUG - // printf("%s %s\n", type, text); - - //const char *values[] = {type, text}; - //int types[] = {(type == NULL) ? SQLITE_NULL : SQLITE_TEXT, SQLITE_TEXT}; - //int lens[] = {-1, -1}; - //sqlite_db_write(NULL, ai->db, LOG_TABLE_INSERT_STMT, values, types, lens, 2); } // MARK: - +static void dbmem_llama_diag_begin(void) { + dbmem_llama_diag[0] = 0; + dbmem_llama_diag_len = 0; + dbmem_llama_diag_enabled = true; +} + +static void dbmem_llama_diag_end(void) { + dbmem_llama_diag_enabled = false; +} + +static const char *dbmem_llama_diag_message(void) { + return dbmem_llama_diag[0] ? dbmem_llama_diag : NULL; +} + static void dbmem_local_set_error(dbmem_local_engine_t *engine, const char *message) { if (!engine || !engine->context) return; dbmem_context_set_error(engine->context, message); } -dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path, char err_msg[DBMEM_ERRBUF_SIZE]) { +static bool dbmem_local_batch_prepare(dbmem_local_engine_t *engine, int n_tokens) { + if (!engine || !engine->batch_initialized) return false; + if (n_tokens <= 0 || n_tokens > engine->tokens_capacity) return false; + + engine->batch.n_tokens = 0; + for (int i = 0; i < n_tokens; i++) { + engine->batch.token[i] = engine->tokens[i]; + engine->batch.pos[i] = i; + engine->batch.n_seq_id[i] = 1; + engine->batch.seq_id[i][0] = 0; + engine->batch.logits[i] = 1; + engine->batch.n_tokens++; + } + + return true; +} + +static int dbmem_local_process_batch(dbmem_local_engine_t *engine) { + if (engine->is_encoder_only) { + return llama_encode(engine->ctx, engine->batch); + } + return llama_decode(engine->ctx, engine->batch); +} + +dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path, int max_context_tokens, char err_msg[DBMEM_ERRBUF_SIZE]) { dbmem_local_engine_t *engine = (dbmem_local_engine_t *)dbmemory_zeroalloc(sizeof(dbmem_local_engine_t)); if (!engine) return NULL; engine->context = (dbmem_context *)ctx; // set logger - llama_log_set(dbmem_logger, engine); + llama_log_set(dbmem_logger, NULL); + dbmem_llama_diag_begin(); // Initialize backend llama_backend_init(); // Load model struct llama_model_params model_params = llama_model_default_params(); + model_params.n_gpu_layers = 0; + model_params.split_mode = LLAMA_SPLIT_MODE_NONE; + model_params.main_gpu = -1; engine->model = llama_model_load_from_file(model_path, model_params); if (!engine->model) { - snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to load model: %s", model_path); + const char *diag = dbmem_llama_diag_message(); + if (diag) { + snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to load model: %s: %s", model_path, diag); + } else { + snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to load model: %s", model_path); + } goto cleanup; } // Get model's native context length int n_ctx_train = llama_model_n_ctx_train(engine->model); + int n_ctx = max_context_tokens * 4; + if (n_ctx < DBMEM_LOCAL_MIN_CONTEXT_TOKENS) n_ctx = DBMEM_LOCAL_MIN_CONTEXT_TOKENS; + if (n_ctx > DBMEM_LOCAL_MAX_CONTEXT_TOKENS) n_ctx = DBMEM_LOCAL_MAX_CONTEXT_TOKENS; + if (n_ctx_train > 0 && n_ctx > n_ctx_train) n_ctx = n_ctx_train; // Create context struct llama_context_params ctx_params = llama_context_default_params(); ctx_params.embeddings = true; - ctx_params.n_ctx = n_ctx_train; - ctx_params.n_batch = n_ctx_train; - ctx_params.n_ubatch = n_ctx_train; + ctx_params.n_ctx = n_ctx; + ctx_params.n_batch = n_ctx; + ctx_params.n_ubatch = n_ctx; + ctx_params.offload_kqv = false; + ctx_params.op_offload = false; engine->ctx = llama_init_from_model(engine->model, ctx_params); if (!engine->ctx) { - snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to create context"); + const char *diag = dbmem_llama_diag_message(); + if (diag) { + snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to create context: %s", diag); + } else { + snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to create context"); + } goto cleanup; } // Get model info engine->vocab = llama_model_get_vocab(engine->model); - engine->n_embd = llama_model_n_embd(engine->model); + engine->n_embd = llama_model_n_embd_out(engine->model); engine->n_ctx = llama_n_ctx(engine->ctx); + engine->n_ubatch = llama_n_ubatch(engine->ctx); engine->pooling = llama_pooling_type(engine->ctx); engine->mem = llama_get_memory(engine->ctx); @@ -159,12 +232,22 @@ dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path // Allocate token buffer engine->tokens_capacity = engine->n_ctx; + if (engine->n_ubatch > 0 && engine->tokens_capacity > engine->n_ubatch) { + engine->tokens_capacity = engine->n_ubatch; + } engine->tokens = (llama_token *)dbmemory_alloc(sizeof(llama_token) * engine->tokens_capacity); if (!engine->tokens) { snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to allocate token buffer"); goto cleanup; } + engine->batch = llama_batch_init(engine->tokens_capacity, 0, 1); + engine->batch_initialized = true; + if (!engine->batch.token || !engine->batch.pos || !engine->batch.n_seq_id || !engine->batch.seq_id || !engine->batch.logits) { + snprintf(err_msg, DBMEM_ERRBUF_SIZE, "Failed to allocate llama batch"); + goto cleanup; + } + // Allocate single embedding buffer engine->embedding = (float *)dbmemory_alloc(sizeof(float) * engine->n_embd); if (!engine->embedding) { @@ -177,9 +260,11 @@ dbmem_local_engine_t *dbmem_local_engine_init (void *ctx, const char *model_path engine->total_tokens_processed = 0; engine->total_embeddings_generated = 0; + dbmem_llama_diag_end(); return engine; cleanup: + dbmem_llama_diag_end(); dbmem_local_engine_free(engine); return NULL; } @@ -190,17 +275,8 @@ bool dbmem_local_engine_warmup (dbmem_local_engine_t *engine) { const char *warmup_text = "Warmup"; int warmup_tokens = llama_tokenize(engine->vocab, warmup_text, (int32_t)strlen(warmup_text), engine->tokens, engine->tokens_capacity, true, true); - if (warmup_tokens > 0) { - struct llama_batch batch = { - .n_tokens = warmup_tokens, - .token = engine->tokens, - .embd = NULL, - .pos = NULL, - .n_seq_id = NULL, - .seq_id = NULL, - .logits = NULL, - }; - llama_encode(engine->ctx, batch); + if (warmup_tokens > 0 && dbmem_local_batch_prepare(engine, warmup_tokens)) { + dbmem_local_process_batch(engine); if (engine->mem != NULL) { llama_memory_clear(engine->mem, true); @@ -215,30 +291,46 @@ int dbmem_local_compute_embedding (dbmem_local_engine_t *engine, const char *tex if (text_len == -1) text_len = (int)strlen(text); if (text_len == 0) return 0; + bool truncated = false; + // Tokenize int n_tokens = llama_tokenize(engine->vocab, text, text_len, engine->tokens, engine->tokens_capacity, true, true); if (n_tokens < 0) { - dbmem_local_set_error(engine, "Tokenization failed (text too long?)"); - return -1; + int needed = -n_tokens; + if (needed <= 0) { + dbmem_local_set_error(engine, "Tokenization failed"); + return -1; + } + + llama_token *all_tokens = (llama_token *)dbmemory_alloc(sizeof(llama_token) * needed); + if (!all_tokens) { + dbmem_local_set_error(engine, "Failed to allocate token overflow buffer"); + return -1; + } + + int full_tokens = llama_tokenize(engine->vocab, text, text_len, all_tokens, needed, true, true); + if (full_tokens < 0) { + dbmemory_free(all_tokens); + dbmem_local_set_error(engine, "Tokenization failed"); + return -1; + } + + n_tokens = engine->tokens_capacity; + memcpy(engine->tokens, all_tokens, sizeof(llama_token) * n_tokens); + dbmemory_free(all_tokens); + truncated = true; } // Handle token overflow: truncate to max context size - int n_tokens_truncated = 0; - if (n_tokens > engine->n_ctx) { - n_tokens_truncated = n_tokens - engine->n_ctx; - n_tokens = engine->n_ctx; + if (n_tokens > engine->tokens_capacity) { + truncated = true; + n_tokens = engine->tokens_capacity; } - // Create batch - struct llama_batch batch = { - .n_tokens = n_tokens, - .token = engine->tokens, - .embd = NULL, - .pos = NULL, - .n_seq_id = NULL, - .seq_id = NULL, - .logits = NULL, - }; + if (!dbmem_local_batch_prepare(engine, n_tokens)) { + dbmem_local_set_error(engine, "Failed to prepare llama batch"); + return -1; + } // Clear memory if (engine->mem != NULL) { @@ -246,9 +338,9 @@ int dbmem_local_compute_embedding (dbmem_local_engine_t *engine, const char *tex } // Encode - int ret = llama_encode(engine->ctx, batch); + int ret = dbmem_local_process_batch(engine); if (ret != 0) { - dbmem_local_set_error(engine, "Llama_encode failed"); + dbmem_local_set_error(engine, "llama batch processing failed"); return -1; } @@ -275,7 +367,7 @@ int dbmem_local_compute_embedding (dbmem_local_engine_t *engine, const char *tex // Fill result result->n_tokens = n_tokens; - result->n_tokens_truncated = n_tokens_truncated; + result->truncated = truncated; result->n_embd = engine->n_embd; result->embedding = engine->embedding; @@ -297,6 +389,11 @@ void dbmem_local_engine_free (dbmem_local_engine_t *engine) { dbmemory_free(engine->tokens); engine->tokens = NULL; } + if (engine->batch_initialized) { + llama_batch_free(engine->batch); + memset(&engine->batch, 0, sizeof(engine->batch)); + engine->batch_initialized = false; + } if (engine->ctx) { llama_free(engine->ctx); engine->ctx = NULL; diff --git a/src/dbmem-parser.c b/src/dbmem-parser.c index 2e69c04..0258baa 100644 --- a/src/dbmem-parser.c +++ b/src/dbmem-parser.c @@ -21,6 +21,7 @@ #include "dbmem-utils.h" #include "md4c.h" +#include #include #include @@ -66,10 +67,19 @@ typedef struct { size_t wp; // Write position in buffer const char *line_end; // End of current line bool skip_html; // Whether to skip HTML tags + bool mdx_mode; // Whether to strip MDX-specific syntax int in_html_tag; // Currently inside multi-line HTML tag int in_fenced_code; // Currently inside fenced code block char fence_char; // Fence character (` or ~) int fence_width; // Number of fence characters + int in_mdx_expr; // Currently inside a multi-line MDX expression + int mdx_expr_depth; // Nested brace depth for MDX expressions + char mdx_expr_quote; // Active quote inside MDX expression + int mdx_expr_block_comment; + int in_mdx_esm; // Currently skipping a multi-line ESM statement + int mdx_esm_depth; // Nested delimiter depth for ESM statements + char mdx_esm_quote; // Active quote inside ESM statement + int mdx_esm_block_comment; } strip_ctx_t; // MARK: - Helpers - @@ -162,6 +172,255 @@ static const char *skip_until (const char *p, const char *end, char c) { return (p < end) ? p + 1 : p; } +static const char *skip_spaces_tabs (const char *p, const char *end) { + while (p < end && (*p == ' ' || *p == '\t')) ++p; + return p; +} + +static int is_ident_continue (char c) { + return (isalnum((unsigned char)c) || c == '_' || c == '$'); +} + +static int starts_keyword (const char *p, const char *end, const char *kw) { + size_t n = strlen(kw); + if ((size_t)(end - p) < n) return 0; + if (strncmp(p, kw, n) != 0) return 0; + return (p + n >= end || !is_ident_continue(p[n])); +} + +static const char *skip_identifier (const char *p, const char *end) { + if (p >= end || !is_ident_continue(*p)) return NULL; + while (p < end && is_ident_continue(*p)) ++p; + return p; +} + +static const char *mdx_top_level_start (const char *p, const char *end) { + int spaces = 0; + while (p < end && *p == ' ') { ++p; ++spaces; } + if (spaces > 3 || (p < end && *p == '\t')) return NULL; + return p; +} + +static int mdx_has_from_keyword (const char *p, const char *end) { + char quote = 0; + int in_block_comment = 0; + + while (p < end) { + char c = *p; + + if (quote) { + if (c == '\\' && p + 1 < end) { + p += 2; + continue; + } + if (c == quote) quote = 0; + ++p; + continue; + } + + if (in_block_comment) { + if (c == '*' && p + 1 < end && p[1] == '/') { + in_block_comment = 0; + p += 2; + continue; + } + ++p; + continue; + } + + if (c == '/' && p + 1 < end) { + if (p[1] == '/') break; + if (p[1] == '*') { + in_block_comment = 1; + p += 2; + continue; + } + } + + if (c == '\'' || c == '"' || c == '`') { + quote = c; + ++p; + continue; + } + + if (starts_keyword(p, end, "from")) return 1; + ++p; + } + + return 0; +} + +static int mdx_line_starts_esm (const char *p, const char *end) { + p = mdx_top_level_start(p, end); + if (!p) return 0; + + if (starts_keyword(p, end, "import")) { + p += strlen("import"); + if (p >= end || (*p != ' ' && *p != '\t')) return 0; + p = skip_spaces_tabs(p, end); + if (p < end && (*p == '\'' || *p == '"')) return 1; + if (p >= end) return 0; + if (*p == '{' || *p == '*') return mdx_has_from_keyword(p, end); + + const char *ident_end = skip_identifier(p, end); + if (!ident_end) return 0; + + if ((size_t)(ident_end - p) == 4 && strncmp(p, "type", 4) == 0) { + p = skip_spaces_tabs(ident_end, end); + if (p < end && (*p == '{' || *p == '*')) return mdx_has_from_keyword(p, end); + ident_end = skip_identifier(p, end); + if (!ident_end) return 0; + } + + p = skip_spaces_tabs(ident_end, end); + if (starts_keyword(p, end, "from")) return 1; + if (p < end && *p == ',') return mdx_has_from_keyword(p + 1, end); + return 0; + } + + if (starts_keyword(p, end, "export")) { + p += strlen("export"); + if (p >= end || (*p != ' ' && *p != '\t')) return 0; + p = skip_spaces_tabs(p, end); + if (p >= end) return 0; + if (*p == '{' || *p == '*') return 1; + return starts_keyword(p, end, "const") || + starts_keyword(p, end, "let") || + starts_keyword(p, end, "var") || + starts_keyword(p, end, "function") || + starts_keyword(p, end, "class") || + starts_keyword(p, end, "default") || + starts_keyword(p, end, "async") || + starts_keyword(p, end, "type") || + starts_keyword(p, end, "interface") || + starts_keyword(p, end, "enum"); + } + + return 0; +} + +static int mdx_update_esm_state (strip_ctx_t *ctx, const char *p, const char *end) { + int saw_semicolon = 0; + + while (p < end) { + char c = *p; + + if (ctx->mdx_esm_quote) { + if (c == '\\' && p + 1 < end) { + p += 2; + continue; + } + if (c == ctx->mdx_esm_quote) ctx->mdx_esm_quote = 0; + ++p; + continue; + } + + if (ctx->mdx_esm_block_comment) { + if (c == '*' && p + 1 < end && p[1] == '/') { + ctx->mdx_esm_block_comment = 0; + p += 2; + continue; + } + ++p; + continue; + } + + if (c == '/' && p + 1 < end) { + if (p[1] == '/') break; + if (p[1] == '*') { + ctx->mdx_esm_block_comment = 1; + p += 2; + continue; + } + } + + if (c == '\'' || c == '"' || c == '`') { + ctx->mdx_esm_quote = c; + ++p; + continue; + } + + if (c == '(' || c == '[' || c == '{') { + ctx->mdx_esm_depth++; + } else if (c == ')' || c == ']' || c == '}') { + if (ctx->mdx_esm_depth > 0) ctx->mdx_esm_depth--; + } else if (c == ';' && ctx->mdx_esm_depth == 0) { + saw_semicolon = 1; + } + + ++p; + } + + if (ctx->mdx_esm_quote || ctx->mdx_esm_block_comment || ctx->mdx_esm_depth > 0) return 0; + return saw_semicolon || ctx->mdx_esm_depth == 0; +} + +static const char *mdx_skip_expression (strip_ctx_t *ctx, const char *p, const char *end) { + if (!ctx->in_mdx_expr) { + ctx->in_mdx_expr = 1; + ctx->mdx_expr_depth = 1; + ctx->mdx_expr_quote = 0; + ctx->mdx_expr_block_comment = 0; + ++p; + } + + while (p < end) { + char c = *p; + + if (ctx->mdx_expr_quote) { + if (c == '\\' && p + 1 < end) { + p += 2; + continue; + } + if (c == ctx->mdx_expr_quote) ctx->mdx_expr_quote = 0; + ++p; + continue; + } + + if (ctx->mdx_expr_block_comment) { + if (c == '*' && p + 1 < end && p[1] == '/') { + ctx->mdx_expr_block_comment = 0; + p += 2; + continue; + } + ++p; + continue; + } + + if (c == '/' && p + 1 < end) { + if (p[1] == '/') break; + if (p[1] == '*') { + ctx->mdx_expr_block_comment = 1; + p += 2; + continue; + } + } + + if (c == '\'' || c == '"' || c == '`') { + ctx->mdx_expr_quote = c; + ++p; + continue; + } + + if (c == '{') { + ctx->mdx_expr_depth++; + } else if (c == '}') { + ctx->mdx_expr_depth--; + ++p; + if (ctx->mdx_expr_depth <= 0) { + ctx->in_mdx_expr = 0; + ctx->mdx_expr_depth = 0; + return p; + } + continue; + } + + ++p; + } + + return p; +} + // Check if line starts a fenced code block. Returns fence width (0 if not a fence). static int check_fence_start (const char *p, const char *end, char *out_char) { p = skip_leading_spaces(p, end, 3); @@ -325,6 +584,25 @@ static void process_inline (strip_ctx_t *ctx, const char *start, const char *end const char *p = start; while (p < end) { + // Continue skipping a multi-line MDX expression. + if (ctx->mdx_mode && ctx->in_mdx_expr) { + p = mdx_skip_expression(ctx, p, end); + continue; + } + + // Escaped opening braces are literal text in MDX. + if (ctx->mdx_mode && *p == '\\' && p + 1 < end && p[1] == '{') { + ctx->buf[ctx->wp++] = '{'; + p += 2; + continue; + } + + // MDX expression: remove the JavaScript expression but keep nearby text. + if (ctx->mdx_mode && *p == '{') { + p = mdx_skip_expression(ctx, p, end); + continue; + } + // HTML tags if (ctx->skip_html && *p == '<') { const char *gt = p + 1; @@ -387,7 +665,7 @@ static void process_inline (strip_ctx_t *ctx, const char *start, const char *end } // Main markdown stripping function -static char *strip_markdown (const char *src, size_t len, size_t *out_len, bool skip_html) { +static char *strip_markdown (const char *src, size_t len, size_t *out_len, bool skip_html, bool mdx_mode) { char *buf = (char *)dbmemory_alloc(len + 1); if (!buf) return NULL; @@ -395,10 +673,19 @@ static char *strip_markdown (const char *src, size_t len, size_t *out_len, bool .buf = buf, .wp = 0, .skip_html = skip_html, + .mdx_mode = mdx_mode, .in_html_tag = 0, .in_fenced_code = 0, .fence_char = 0, - .fence_width = 0 + .fence_width = 0, + .in_mdx_expr = 0, + .mdx_expr_depth = 0, + .mdx_expr_quote = 0, + .mdx_expr_block_comment = 0, + .in_mdx_esm = 0, + .mdx_esm_depth = 0, + .mdx_esm_quote = 0, + .mdx_esm_block_comment = 0 }; const char *p = src; @@ -451,6 +738,21 @@ static char *strip_markdown (const char *src, size_t len, size_t *out_len, bool continue; } + // MDX top-level ESM (import/export) is scaffolding, not searchable prose. + if (ctx.mdx_mode) { + if (ctx.in_mdx_esm) { + if (mdx_update_esm_state(&ctx, p, line_end)) ctx.in_mdx_esm = 0; + p = (nl < end) ? nl + 1 : nl; + continue; + } + + if (mdx_line_starts_esm(p, line_end)) { + ctx.in_mdx_esm = !mdx_update_esm_state(&ctx, p, line_end); + p = (nl < end) ? nl + 1 : nl; + continue; + } + } + // Blank lines const char *tp = p; while (tp < line_end && (*tp == ' ' || *tp == '\t')) ++tp; @@ -630,10 +932,10 @@ static int parse_sections (const char *buffer, size_t buffer_size, bool skip_sem } // Strip markdown from all sections -static int strip_sections (parse_ctx_t *ctx, const char *buffer, bool skip_html) { +static int strip_sections (parse_ctx_t *ctx, const char *buffer, bool skip_html, bool mdx_mode) { for (size_t i = 0; i < ctx->sec_count; i++) { section_t *s = &ctx->sections[i]; - s->text = strip_markdown(buffer + s->start, s->end - s->start, &s->text_len, skip_html); + s->text = strip_markdown(buffer + s->start, s->end - s->start, &s->text_len, skip_html, mdx_mode); if (!s->text) { // Free previously allocated texts and set to NULL to avoid double-free for (size_t j = 0; j < i; j++) { @@ -761,7 +1063,7 @@ int dbmem_parse (const char *md, size_t md_len, dbmem_parse_settings *settings) } // 2. Strip markdown from sections - if (strip_sections(&ctx, md, settings->skip_html) != 0) { + if (strip_sections(&ctx, md, settings->skip_html, settings->mdx_mode) != 0) { free_sections(&ctx); return -1; } @@ -808,8 +1110,12 @@ int dbmem_parse (const char *md, size_t md_len, dbmem_parse_settings *settings) src_len = src_end - src_off; } - // Invoke callback - if (settings->callback) { + // Invoke callback (skip whitespace-only chunks) + bool has_text = false; + for (size_t k = 0; k < chunk_len; k++) { + if (!isspace((unsigned char)chunk_text[k])) { has_text = true; break; } + } + if (has_text && settings->callback) { rc = settings->callback(chunk_text, chunk_len, src_off, src_len, settings->xdata, i); if (rc != 0) break; } diff --git a/src/dbmem-parser.h b/src/dbmem-parser.h index 27d6cd2..5ff0e61 100644 --- a/src/dbmem-parser.h +++ b/src/dbmem-parser.h @@ -20,6 +20,7 @@ typedef struct { size_t chars_per_token; // estimated number of characters per token bool skip_semantic; // if true, do not semantically parse MD file bool skip_html; // if true, remove html tags + bool mdx_mode; // if true, strip MDX ESM and expressions } dbmem_parse_settings; int dbmem_parse (const char *md, size_t md_len, dbmem_parse_settings *settings); diff --git a/src/dbmem-rembed.c b/src/dbmem-rembed.c index 0613ed4..d4ca288 100644 --- a/src/dbmem-rembed.c +++ b/src/dbmem-rembed.c @@ -210,6 +210,62 @@ static int set_json_error_message (dbmem_remote_engine_t *engine) { return -1; } +static int dbmem_json_skip_token (const jsmntok_t *tokens, int index) { + int next = index + 1; + + if (tokens[index].type == JSMN_ARRAY) { + for (int i = 0; i < tokens[index].size; i++) { + next = dbmem_json_skip_token(tokens, next); + } + return next; + } + + if (tokens[index].type == JSMN_OBJECT) { + for (int i = 0; i < tokens[index].size; i++) { + next += 1; // skip key token + next = dbmem_json_skip_token(tokens, next); + } + return next; + } + + return next; +} + +static bool dbmem_json_token_equals (const char *json, const jsmntok_t *token, const char *text) { + size_t len = strlen(text); + size_t token_len = (size_t)(token->end - token->start); + return token_len == len && memcmp(json + token->start, text, len) == 0; +} + +static int dbmem_json_object_find (const char *json, const jsmntok_t *tokens, int object_index, const char *key) { + if (object_index < 0 || tokens[object_index].type != JSMN_OBJECT) return -1; + + int index = object_index + 1; + for (int i = 0; i < tokens[object_index].size; i++) { + int key_index = index; + int value_index = key_index + 1; + + if (tokens[key_index].type != JSMN_STRING) return -1; + if (dbmem_json_token_equals(json, &tokens[key_index], key)) return value_index; + + index = dbmem_json_skip_token(tokens, value_index); + } + + return -1; +} + +static bool dbmem_json_parse_bool (const char *json, const jsmntok_t *token) { + size_t len = (size_t)(token->end - token->start); + return token->type == JSMN_PRIMITIVE && len == 4 && memcmp(json + token->start, "true", 4) == 0; +} + +#if ENABLE_DBMEM_DEBUG_EMBEDDING +static void dbmem_remote_debug_log_response(dbmem_remote_engine_t *engine, long http_code) { + const char *response = engine->data ? engine->data : ""; + DEBUG_DBMEM_ALWAYS("[dbmem-rembed] vectors.space response (HTTP %ld): %s", http_code, response); +} +#endif + // MARK: - dbmem_remote_engine_t *dbmem_remote_engine_init (void *ctx, const char *provider, const char *model, char err_msg[DBMEM_ERRBUF_SIZE]) { @@ -450,6 +506,10 @@ int dbmem_remote_compute_embedding (dbmem_remote_engine_t *engine, const char *t sqlite3_free(response_data); #endif +#if ENABLE_DBMEM_DEBUG_EMBEDDING + dbmem_remote_debug_log_response(engine, http_code); +#endif + if (http_code != 200) { return set_json_error_message(engine); } @@ -480,32 +540,63 @@ int dbmem_remote_compute_embedding (dbmem_remote_engine_t *engine, const char *t // extract fields int n_embd = 0; - int prompt_tokens = 0; - int estimated_prompt_tokens = 0; + int request_tokens = 0; + bool truncated = false; int emb_start = -1; size_t emb_count = 0; - for (int i = 0; i < ntokens - 1; i++) { - if (tokens[i].type != JSMN_STRING) continue; - int klen = tokens[i].end - tokens[i].start; - const char *key = engine->data + tokens[i].start; - - if (klen == 9 && memcmp(key, "embedding", 9) == 0 && tokens[i + 1].type == JSMN_ARRAY) { - if (tokens[i + 1].size <= 0) { - dbmem_context_set_error(engine->context, "Invalid embedding array size in API response"); - return -1; - } - emb_count = (size_t)tokens[i + 1].size; - emb_start = i + 2; - } else if (klen == 16 && memcmp(key, "output_dimension", 16) == 0) { - n_embd = atoi(engine->data + tokens[i + 1].start); - } else if (klen == 13 && memcmp(key, "prompt_tokens", 13) == 0 && tokens[i + 1].type == JSMN_PRIMITIVE) { - prompt_tokens = atoi(engine->data + tokens[i + 1].start); - } else if (klen == 23 && memcmp(key, "estimated_prompt_tokens", 23) == 0) { - estimated_prompt_tokens = atoi(engine->data + tokens[i + 1].start); + if (tokens[0].type != JSMN_OBJECT) { + dbmem_context_set_error(engine->context, "Invalid API response shape"); + return -1; + } + + int output_dimension_index = dbmem_json_object_find(engine->data, tokens, 0, "output_dimension"); + if (output_dimension_index >= 0 && tokens[output_dimension_index].type == JSMN_PRIMITIVE) { + n_embd = atoi(engine->data + tokens[output_dimension_index].start); + } + + int data_index = dbmem_json_object_find(engine->data, tokens, 0, "data"); + if (data_index < 0 || tokens[data_index].type != JSMN_ARRAY || tokens[data_index].size <= 0) { + dbmem_context_set_error(engine->context, "Missing embedding data in API response"); + return -1; + } + + int item_index = data_index + 1; + if (tokens[item_index].type != JSMN_OBJECT) { + dbmem_context_set_error(engine->context, "Invalid embedding item in API response"); + return -1; + } + + int embedding_index = dbmem_json_object_find(engine->data, tokens, item_index, "embedding"); + if (embedding_index < 0 || tokens[embedding_index].type != JSMN_ARRAY) { + dbmem_context_set_error(engine->context, "Missing embedding data in API response"); + return -1; + } + if (tokens[embedding_index].size <= 0) { + dbmem_context_set_error(engine->context, "Invalid embedding array size in API response"); + return -1; + } + emb_count = (size_t)tokens[embedding_index].size; + emb_start = embedding_index + 1; + + int truncated_index = dbmem_json_object_find(engine->data, tokens, item_index, "truncated"); + if (truncated_index >= 0) { + truncated = dbmem_json_parse_bool(engine->data, &tokens[truncated_index]); + } + + int usage_index = dbmem_json_object_find(engine->data, tokens, 0, "usage"); + if (usage_index >= 0 && tokens[usage_index].type == JSMN_OBJECT) { + int request_tokens_index = dbmem_json_object_find(engine->data, tokens, usage_index, "request_tokens"); + if (request_tokens_index >= 0 && tokens[request_tokens_index].type == JSMN_PRIMITIVE) { + request_tokens = atoi(engine->data + tokens[request_tokens_index].start); } } + // Some providers do not return output_dimension; fallback to embedding array length. + if (n_embd == 0 && emb_count > 0) { + n_embd = (int)emb_count; + } + if (emb_start < 0 || emb_count == 0 || n_embd == 0) { dbmem_context_set_error(engine->context, "Missing embedding data in API response"); return -1; @@ -529,12 +620,12 @@ int dbmem_remote_compute_embedding (dbmem_remote_engine_t *engine, const char *t // Fill result result->n_embd = n_embd; - result->n_tokens = prompt_tokens; - result->n_tokens_truncated = (estimated_prompt_tokens > prompt_tokens) ? estimated_prompt_tokens - prompt_tokens : 0; + result->n_tokens = request_tokens; + result->truncated = truncated; result->embedding = engine->embedding; // Update statistics - engine->total_tokens_processed += prompt_tokens; + engine->total_tokens_processed += result->n_tokens; engine->total_embeddings_generated++; return 0; diff --git a/src/dbmem-search.c b/src/dbmem-search.c index 7dec56d..a176e55 100644 --- a/src/dbmem-search.c +++ b/src/dbmem-search.c @@ -656,6 +656,12 @@ static int vMemorySearchCursorFilter (sqlite3_vtab_cursor *cur, int idxNum, cons if (rc != SQLITE_OK) return SQLITE_NOMEM; // perform semantic search + rc = dbmem_context_ensure_engine(ctx); + if (rc != SQLITE_OK) { + sqlvTab->zErrMsg = sqlite3_mprintf("%s", dbmem_context_errmsg(ctx)); + return SQLITE_ERROR; + } + // retrieve engine bool is_local; void *engine = dbmem_context_engine(ctx, &is_local); diff --git a/src/sqlite-memory.c b/src/sqlite-memory.c index d82ca9e..a783f5a 100644 --- a/src/sqlite-memory.c +++ b/src/sqlite-memory.c @@ -8,10 +8,14 @@ #include #include #include -#include #include #include #include +#include +#include +#ifdef _WIN32 +#include +#endif #ifndef _WIN32 #include #endif @@ -60,6 +64,11 @@ SQLITE_EXTENSION_INIT1 #define DBMEM_SETTINGS_KEY_EMBEDDING_CACHE "embedding_cache" #define DBMEM_SETTINGS_KEY_CACHE_MAX_ENTRIES "cache_max_entries" #define DBMEM_SETTINGS_KEY_SEARCH_OVERSAMPLE "search_oversample" +#define DBMEM_SETTINGS_KEY_PRESERVE_DUP_PATHS "preserve_duplicate_paths" +#define DBMEM_SETTINGS_KEY_DEFER_EMBEDDINGS "defer_embeddings" +#define DBMEM_SETTINGS_KEY_SCHEMA_VERSION "schema_version" + +#define DBMEM_SCHEMA_VERSION 4 // default values from https://docs.openclaw.ai/concepts/memory #define DEFAULT_CHARS_PER_TOKEN 4 // Approximate number of characters per token (GPT ≈ 4, Claude ≈ 3.5) @@ -103,7 +112,7 @@ struct dbmem_context { bool skip_semantic; // Skip markdown parsing, treat as raw text bool skip_html; // Strip HTML tags when parsing markdown bool perform_fts; // Enable/Disable FTS during search - + bool vector_extension_available; // SQLite-vector available and correctly loaded flag bool sync_extension_available; // SQLite-sync available and correctly loadedflag bool sync_enabled; // True when memory_enable_sync has been successfully called @@ -119,6 +128,8 @@ struct dbmem_context { bool embedding_cache; // Enable/disable embedding cache (default: true) int cache_max_entries; // Max cache entries (0 = no limit) int search_oversample; // Search oversampling multiplier (0 = no oversampling) + bool preserve_duplicate_paths; // Keep separate rows for distinct paths with identical content + bool defer_embeddings; // Store content without computing embeddings (use memory_embed_pending later) // Cache float *cache_buffer; // Reusable buffer for cache hits @@ -126,12 +137,33 @@ struct dbmem_context { // Runtime state int64_t counter; // Chunk counter during file processing + int64_t chunks_added; // Vault rows inserted while processing the current buffer uint64_t hash; // Hash of the current text const char *context; // Optional context string for current operation - const char *path; // Full path file (optional) + const char *path; // Portable relative file path (optional) + const char *source_path; // Local filesystem provenance for current operation (optional) + const char *root_path; // Filesystem root for current IO operation (optional) char error_msg[DBMEM_ERRBUF_SIZE]; // Error message buffer }; +// MARK: - Internal prototypes + +typedef struct dbmem_string_list dbmem_string_list; +typedef struct dbmem_json_buffer dbmem_json_buffer; + +static int dbmem_database_begin_transaction (sqlite3 *db); +static int dbmem_database_commit_transaction (sqlite3 *db); +static int dbmem_database_rollback_transaction (sqlite3 *db); +static int dbmem_json_append_tree_children (dbmem_json_buffer *json, dbmem_string_list *paths, const unsigned char *flags, int start, int end, size_t offset); +static char *dbmem_path_normalized_copy (const char *path); +static char *dbmem_path_unique_storage_copy (sqlite3 *db, const char *preferred_path, const char *source_path); +static char *dbmem_path_directory_marker_storage_copy (const char *path); +static char *dbmem_path_directory_file_storage_copy (const char *path); +static bool dbmem_path_is_directory_marker (const char *path); +static bool dbmem_path_has_trailing_separator (const char *path); +static int dbmem_database_add_directory_marker (dbmem_context *ctx, const char *path, const char *buffer, int64_t len); +static int dbmem_reindex (dbmem_context *ctx); + static bool fts5_is_available = true; static int dbmem_bind_hash (sqlite3_stmt *vm, int index, uint64_t hash) { @@ -159,28 +191,38 @@ static bool dbmem_value_hash (sqlite3_value *value, uint64_t *hash) { } } +static uint64_t dbmem_storage_hash_compute (const char *buffer, size_t len, const char *path, bool preserve_duplicate_paths) { + uint64_t content_hash = dbmem_hash_compute(buffer, len); + if (!preserve_duplicate_paths || !path || !path[0]) return content_hash; + + uint64_t parts[2]; + parts[0] = content_hash; + parts[1] = dbmem_hash_compute(path, strlen(path)); + return dbmem_hash_compute(parts, sizeof(parts)); +} + // MARK: - Settings - static int dbmem_settings_write (sqlite3 *db, const char *key, const char *text_value, sqlite3_int64 int_value, const sqlite3_value *sql_value, int bind_type) { static const char *sql = "REPLACE INTO dbmem_settings (key, value) VALUES (?1, ?2);"; - + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_bind_text(vm, 1, key, -1, NULL); if (rc != SQLITE_OK) goto cleanup; - + switch (bind_type) { case SQLITE_TEXT: rc = sqlite3_bind_text(vm, 2, text_value, -1, NULL); break; case SQLITE_INTEGER: rc = sqlite3_bind_int64(vm, 2, int_value); break; case DBMEM_TYPE_VALUE: rc = sqlite3_bind_value(vm, 2, sql_value); break; default: rc = SQLITE_MISUSE; goto cleanup; } - + rc = sqlite3_step(vm); if (rc == SQLITE_DONE) rc = SQLITE_OK; - + cleanup: if (rc != SQLITE_OK) DEBUG_DBMEM("Error in dbmem_settings_write: %s", sqlite3_errmsg(db)); if (vm) sqlite3_finalize(vm); @@ -201,67 +243,67 @@ static int dbmem_settings_write_value (sqlite3 *db, const char *key, sqlite3_val static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_value *value) { if (!value) return 0; - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_MAX_TOKENS) == 0) { int n = sqlite3_value_int(value); if (n > 0) ctx->max_tokens = n; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_OVERLAY_TOKENS) == 0) { int n = sqlite3_value_int(value); if (n > 0) ctx->overlay_tokens = n; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_CHARS_PER_TOKENS) == 0) { int n = sqlite3_value_int(value); if (n > 0) ctx->chars_per_tokens = n; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_DIMENSION) == 0) { int n = sqlite3_value_int(value); if (n > 0) {ctx->dimension = n; ctx->dimension_saved = true;} return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_SAVE_CONTEXT) == 0) { int n = sqlite3_value_int(value); ctx->save_content = (n > 0) ? 1 : 0; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_SKIP_SEMANTIC) == 0) { int n = sqlite3_value_int(value); ctx->skip_semantic = (n > 0) ? 1 : 0; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_SKIP_HTML) == 0) { int n = sqlite3_value_int(value); ctx->skip_html = (n > 0) ? 1 : 0; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_ENGINE_WARMUP) == 0) { int n = sqlite3_value_int(value); ctx->engine_warmup = (n > 0) ? 1 : 0; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_FTS_ENABLED) == 0) { int n = sqlite3_value_int(value); ctx->perform_fts = (n > 0) ? 1 : 0; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_MAX_RESULTS) == 0) { int n = sqlite3_value_int(value); if (n >= 0) ctx->max_results = n; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_VECTOR_WEIGHT) == 0) { double n = sqlite3_value_double(value); if (n >= 0) ctx->vector_weight = n; @@ -273,7 +315,7 @@ static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_val if (n >= 0) ctx->text_weight = n; return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_MIN_SCORE) == 0) { double n = sqlite3_value_double(value); if (n >= 0) ctx->min_score = n; @@ -304,6 +346,18 @@ static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_val return 0; } + if (strcasecmp(key, DBMEM_SETTINGS_KEY_PRESERVE_DUP_PATHS) == 0) { + int n = sqlite3_value_int(value); + ctx->preserve_duplicate_paths = (n > 0) ? 1 : 0; + return 0; + } + + if (strcasecmp(key, DBMEM_SETTINGS_KEY_DEFER_EMBEDDINGS) == 0) { + int n = sqlite3_value_int(value); + ctx->defer_embeddings = (n > 0) ? 1 : 0; + return 0; + } + if (strcasecmp(key, DBMEM_SETTINGS_KEY_PROVIDER) == 0) { char *provider = dbmem_strdup((const char *)sqlite3_value_text(value)); if (provider) { @@ -312,7 +366,7 @@ static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_val } return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_MODEL) == 0) { char *model = dbmem_strdup((const char *)sqlite3_value_text(value)); if (model) { @@ -321,7 +375,7 @@ static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_val } return 0; } - + if (strcasecmp(key, DBMEM_SETTINGS_KEY_EXTENSIONS) == 0) { char *extensions = dbmem_strdup((const char *)sqlite3_value_text(value)); if (extensions) { @@ -330,27 +384,27 @@ static int dbmem_settings_sync (dbmem_context *ctx, const char *key, sqlite3_val } return 0; } - + return 0; } void dbmem_settings_load (sqlite3 *db, dbmem_context *ctx) { const char *sql = "SELECT key, value FROM dbmem_settings;"; - + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + while (1) { // no error handling here rc = sqlite3_step(vm); if (rc != SQLITE_ROW) break; - + const char *key = (const char *)sqlite3_column_text(vm, 0); if (!key) continue; dbmem_settings_sync(ctx, key, sqlite3_column_value(vm, 1)); } - + cleanup: if (vm) sqlite3_finalize(vm); return; @@ -358,30 +412,270 @@ void dbmem_settings_load (sqlite3 *db, dbmem_context *ctx) { // MARK: - Database - +static bool dbmem_database_column_exists (sqlite3 *db, const char *table, const char *column, int *out_rc) { + char sql[256]; + snprintf(sql, sizeof(sql), "PRAGMA table_info(%s);", table); + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) { + if (out_rc) *out_rc = rc; + return false; + } + + bool exists = false; + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(vm, 1); + if (name && strcmp(name, column) == 0) { + exists = true; + break; + } + } + + if (rc == SQLITE_DONE || rc == SQLITE_ROW) rc = SQLITE_OK; + sqlite3_finalize(vm); + if (out_rc) *out_rc = rc; + return exists; +} + +static int dbmem_database_add_column_if_missing (sqlite3 *db, const char *table, const char *column, const char *alter_sql) { + int rc = SQLITE_OK; + if (dbmem_database_column_exists(db, table, column, &rc)) return SQLITE_OK; + if (rc != SQLITE_OK) return rc; + return sqlite3_exec(db, alter_sql, NULL, NULL, NULL); +} + +static bool dbmem_database_table_exists (sqlite3 *db, const char *table, int *out_rc) { + static const char *sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1 LIMIT 1;"; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) { + if (out_rc) *out_rc = rc; + return false; + } + + rc = sqlite3_bind_text(vm, 1, table, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + sqlite3_finalize(vm); + if (out_rc) *out_rc = rc; + return false; + } + + rc = sqlite3_step(vm); + bool exists = rc == SQLITE_ROW; + if (rc == SQLITE_ROW || rc == SQLITE_DONE) rc = SQLITE_OK; + + sqlite3_finalize(vm); + if (out_rc) *out_rc = rc; + return exists; +} + +static int dbmem_database_schema_version (sqlite3 *db, int *version) { + static const char *sql = "SELECT value FROM dbmem_settings WHERE key=?1 LIMIT 1;"; + + *version = 0; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_text(vm, 1, DBMEM_SETTINGS_KEY_SCHEMA_VERSION, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + *version = sqlite3_column_int(vm, 0); + rc = SQLITE_OK; + } else if (rc == SQLITE_DONE) { + rc = SQLITE_OK; + } + +cleanup: + if (vm) sqlite3_finalize(vm); + return rc; +} + +static bool dbmem_database_is_enabled (sqlite3 *db, int *out_rc) { + static const char *tables[] = { + "dbmem_settings", + "dbmem_content", + "dbmem_content_source", + "dbmem_vault", + "dbmem_cache" + }; + + int rc = SQLITE_OK; + for (size_t i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) { + if (!dbmem_database_table_exists(db, tables[i], &rc)) { + if (out_rc) *out_rc = rc; + return false; + } + if (rc != SQLITE_OK) { + if (out_rc) *out_rc = rc; + return false; + } + } + + if (out_rc) *out_rc = rc; + return true; +} + +static int dbmem_database_set_schema_version (sqlite3 *db, int version) { + return dbmem_settings_write_int(db, DBMEM_SETTINGS_KEY_SCHEMA_VERSION, version); +} + +static int dbmem_database_migrate_v1_to_v2 (sqlite3 *db) { + int rc = dbmem_database_add_column_if_missing(db, "dbmem_vault", "n_tokens", + "ALTER TABLE dbmem_vault ADD COLUMN n_tokens INTEGER NOT NULL DEFAULT 0;"); + if (rc != SQLITE_OK) return rc; + + rc = dbmem_database_add_column_if_missing(db, "dbmem_vault", "truncated", + "ALTER TABLE dbmem_vault ADD COLUMN truncated INTEGER NOT NULL DEFAULT 0;"); + if (rc != SQLITE_OK) return rc; + + rc = dbmem_database_add_column_if_missing(db, "dbmem_cache", "n_tokens", + "ALTER TABLE dbmem_cache ADD COLUMN n_tokens INTEGER NOT NULL DEFAULT 0;"); + if (rc != SQLITE_OK) return rc; + + return dbmem_database_add_column_if_missing(db, "dbmem_cache", "truncated", + "ALTER TABLE dbmem_cache ADD COLUMN truncated INTEGER NOT NULL DEFAULT 0;"); +} + +static int dbmem_database_create_source_table (sqlite3 *db) { + return sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS dbmem_content_source (" + "path TEXT PRIMARY KEY NOT NULL, " + "source_path TEXT NOT NULL UNIQUE" + ");", + NULL, NULL, NULL); +} + +static int dbmem_database_migrate_v2_to_v3 (sqlite3 *db) { + return dbmem_database_create_source_table(db); +} + +static int dbmem_database_migrate_v3_to_v4 (sqlite3 *db) { + int rc = dbmem_database_create_source_table(db); + if (rc != SQLITE_OK) return rc; + + rc = dbmem_database_begin_transaction(db); + if (rc != SQLITE_OK) return rc; + + sqlite3_int64 has_source_path = 0; + sqlite3_stmt *vm = NULL; + rc = sqlite3_prepare_v2(db, + "SELECT COUNT(*) FROM pragma_table_info('dbmem_content') WHERE name='source_path';", + -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + if (sqlite3_step(vm) == SQLITE_ROW) has_source_path = sqlite3_column_int64(vm, 0); + sqlite3_finalize(vm); + vm = NULL; + + if (has_source_path == 0) { + rc = dbmem_database_commit_transaction(db); + return rc; + } + + rc = sqlite3_exec(db, + "INSERT OR REPLACE INTO dbmem_content_source (path, source_path) " + "SELECT path, source_path FROM dbmem_content " + "WHERE source_path IS NOT NULL AND source_path != '';", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_exec(db, + "ALTER TABLE dbmem_content RENAME TO dbmem_content_old;" + "CREATE TABLE dbmem_content (" + "hash TEXT PRIMARY KEY NOT NULL, " + "path TEXT NOT NULL DEFAULT '' UNIQUE, " + "value TEXT DEFAULT NULL, " + "length INTEGER NOT NULL DEFAULT 0, " + "context TEXT DEFAULT NULL, " + "created_at INTEGER DEFAULT 0, " + "last_accessed INTEGER DEFAULT 0" + ");" + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at, last_accessed) " + "SELECT hash, path, value, length, context, created_at, last_accessed FROM dbmem_content_old;" + "DROP TABLE dbmem_content_old;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + + rc = dbmem_database_commit_transaction(db); + return rc; + +rollback: + if (vm) sqlite3_finalize(vm); + dbmem_database_rollback_transaction(db); + return rc; +} + +static int dbmem_database_migrate (sqlite3 *db) { + int version = 0; + int rc = dbmem_database_schema_version(db, &version); + if (rc != SQLITE_OK) return rc; + + if (version > DBMEM_SCHEMA_VERSION) return SQLITE_MISMATCH; + if (version <= 0) version = 1; + + if (version < 2) { + rc = dbmem_database_migrate_v1_to_v2(db); + if (rc != SQLITE_OK) return rc; + version = 2; + rc = dbmem_database_set_schema_version(db, version); + if (rc != SQLITE_OK) return rc; + } + + if (version < 3) { + rc = dbmem_database_migrate_v2_to_v3(db); + if (rc != SQLITE_OK) return rc; + version = 3; + rc = dbmem_database_set_schema_version(db, version); + if (rc != SQLITE_OK) return rc; + } + + if (version < 4) { + rc = dbmem_database_migrate_v3_to_v4(db); + if (rc != SQLITE_OK) return rc; + version = 4; + rc = dbmem_database_set_schema_version(db, version); + if (rc != SQLITE_OK) return rc; + } + + if (version != DBMEM_SCHEMA_VERSION) return SQLITE_MISMATCH; + return SQLITE_OK; +} + static int dbmem_database_init (sqlite3 *db) { const char *sql = "CREATE TABLE IF NOT EXISTS dbmem_settings (key TEXT PRIMARY KEY, value TEXT);"; int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - + sql = "CREATE TABLE IF NOT EXISTS dbmem_content (hash TEXT PRIMARY KEY NOT NULL, path TEXT NOT NULL DEFAULT '' UNIQUE, value TEXT DEFAULT NULL, length INTEGER NOT NULL DEFAULT 0, context TEXT DEFAULT NULL, created_at INTEGER DEFAULT 0, last_accessed INTEGER DEFAULT 0);"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - - sql = "CREATE TABLE IF NOT EXISTS dbmem_vault (hash TEXT NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY (hash, seq));"; + + rc = dbmem_database_create_source_table(db); + if (rc != SQLITE_OK) return rc; + + sql = "CREATE TABLE IF NOT EXISTS dbmem_vault (hash TEXT NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, n_tokens INTEGER NOT NULL DEFAULT 0, truncated INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (hash, seq));"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; - - sql = "CREATE TABLE IF NOT EXISTS dbmem_cache (text_hash TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, PRIMARY KEY (text_hash, provider, model));"; + + sql = "CREATE TABLE IF NOT EXISTS dbmem_cache (text_hash TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, n_tokens INTEGER NOT NULL DEFAULT 0, truncated INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (text_hash, provider, model));"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; + rc = dbmem_database_migrate(db); + if (rc != SQLITE_OK) return rc; + sql = "CREATE VIRTUAL TABLE IF NOT EXISTS dbmem_vault_fts USING fts5 (content, hash UNINDEXED, seq UNINDEXED, context UNINDEXED);"; rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc != SQLITE_OK) { fts5_is_available = false; rc = SQLITE_OK; } - + // explicitly allows extension loading (only available when linked statically) // when loaded dynamically, the calling application must enable extension loading #if defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) @@ -394,28 +688,45 @@ static int dbmem_database_init (sqlite3 *db) { static bool dbmem_database_check_if_stored (sqlite3 *db, uint64_t hash, int64_t len) { static const char *sql = "SELECT length FROM dbmem_content WHERE hash=? LIMIT 1;"; - + bool result = false; sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + rc = dbmem_bind_hash(vm, 1, hash); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_step(vm); if (rc == SQLITE_DONE) rc = SQLITE_OK; else if (rc != SQLITE_ROW) goto cleanup; - - // SQLITE_ROW case - sqlite3_int64 saved_len = sqlite3_column_int64(vm, 0); - result = (saved_len == len); - + else { + sqlite3_int64 saved_len = sqlite3_column_int64(vm, 0); + result = (saved_len == len); + } + cleanup: if (vm) sqlite3_finalize(vm); return result; } +static char *dbmem_database_path_for_hash_copy (sqlite3 *db, uint64_t hash) { + sqlite3_stmt *vm = NULL; + char *path = NULL; + + int rc = sqlite3_prepare_v2(db, "SELECT path FROM dbmem_content WHERE hash=?1 LIMIT 1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto cleanup; + if (sqlite3_step(vm) == SQLITE_ROW) { + path = dbmem_strdup((const char *)sqlite3_column_text(vm, 0)); + } + +cleanup: + if (vm) sqlite3_finalize(vm); + return path; +} + static void dbmem_database_delete_hash (sqlite3 *db, uint64_t hash) { sqlite3_stmt *vm = NULL; if (fts5_is_available) { @@ -429,96 +740,281 @@ static void dbmem_database_delete_hash (sqlite3 *db, uint64_t hash) { sqlite3_step(vm); sqlite3_finalize(vm); + sqlite3_prepare_v2(db, "DELETE FROM dbmem_content_source WHERE path IN (SELECT path FROM dbmem_content WHERE hash=?1);", -1, &vm, NULL); + dbmem_bind_hash(vm, 1, hash); + sqlite3_step(vm); + sqlite3_finalize(vm); + sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE hash=?1;", -1, &vm, NULL); dbmem_bind_hash(vm, 1, hash); sqlite3_step(vm); sqlite3_finalize(vm); } -static void dbmem_database_delete_stale_path (sqlite3 *db, const char *path, uint64_t new_hash) { - if (!path) return; - +static int dbmem_database_delete_index_hash (sqlite3 *db, uint64_t hash) { sqlite3_stmt *vm = NULL; - int rc = sqlite3_prepare_v2(db, "SELECT hash FROM dbmem_content WHERE path=?1;", -1, &vm, NULL); - if (rc != SQLITE_OK) return; + int rc = SQLITE_OK; - sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); - rc = sqlite3_step(vm); - if (rc == SQLITE_ROW) { - uint64_t old_hash = 0; - bool has_old_hash = dbmem_column_hash(vm, 0, &old_hash); - sqlite3_finalize(vm); - if (has_old_hash && old_hash != new_hash) { - dbmem_database_delete_hash(db, old_hash); - } - } else { + if (fts5_is_available) { + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault_fts WHERE hash=?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; sqlite3_finalize(vm); + vm = NULL; + if (rc != SQLITE_OK) goto cleanup; } -} - -static int dbmem_database_add_entry (dbmem_context *ctx, sqlite3 *db, uint64_t hash, const char *buffer, int64_t len) { - static const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6);"; - sqlite3_stmt *vm = NULL; - int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault WHERE hash=?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - rc = dbmem_bind_hash(vm, 1, hash); if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; - const char *path = ctx->path; - char uuid[DBMEM_UUID_STR_MAXLEN]; - if (path == NULL) path = dbmem_uuid_v7(uuid); - rc = sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); - if (rc != SQLITE_OK) goto cleanup; +cleanup: + if (vm) sqlite3_finalize(vm); + return rc; +} - if (len > (int64_t)INT_MAX) { rc = SQLITE_TOOBIG; goto cleanup; } - rc = (ctx->save_content) ? sqlite3_bind_text(vm, 3, buffer, (int)len, SQLITE_STATIC) : sqlite3_bind_null(vm, 3); - if (rc != SQLITE_OK) goto cleanup; +static bool dbmem_database_hash_has_vault (sqlite3 *db, uint64_t hash) { + sqlite3_stmt *vm = NULL; + bool found = false; - rc = sqlite3_bind_int64(vm, 4, (sqlite3_int64)len); + int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM dbmem_vault WHERE hash=?1 LIMIT 1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - - rc = (ctx->context) ? sqlite3_bind_text(vm, 5, ctx->context, -1, SQLITE_STATIC) : sqlite3_bind_null(vm, 5); + rc = dbmem_bind_hash(vm, 1, hash); if (rc != SQLITE_OK) goto cleanup; + found = (sqlite3_step(vm) == SQLITE_ROW); - rc = sqlite3_bind_int64(vm, 6, (sqlite3_int64)time(NULL)); - if (rc != SQLITE_OK) goto cleanup; +cleanup: + if (vm) sqlite3_finalize(vm); + return found; +} +static int dbmem_database_update_content_hash (sqlite3 *db, const char *path, uint64_t hash) { + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, "UPDATE dbmem_content SET hash = ?1 WHERE path = ?2 AND hash != ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; rc = sqlite3_step(vm); if (rc == SQLITE_DONE) rc = SQLITE_OK; cleanup: - if (rc != SQLITE_OK) DEBUG_DBMEM_ALWAYS("Error in dbmem_database_add_entry: %s", sqlite3_errmsg(ctx->db)); if (vm) sqlite3_finalize(vm); return rc; } -static int dbmem_database_add_chunk (dbmem_context *ctx, embedding_result_t *result, size_t offset, size_t length, size_t index) { - static const char *sql = "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES (?1, ?2, ?3, ?4, ?5);"; - +static int dbmem_database_path_exists (sqlite3 *db, const char *path, bool *exists) { sqlite3_stmt *vm = NULL; - int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); - if (rc != SQLITE_OK) goto cleanup; - - rc = dbmem_bind_hash(vm, 1, ctx->hash); + int rc = sqlite3_prepare_v2(db, "SELECT 1 FROM dbmem_content WHERE path=?1 LIMIT 1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_int64(vm, 2, (sqlite3_int64)index); - if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_blob(vm, 3, result->embedding, (int)(result->n_embd * sizeof(float)), SQLITE_STATIC); + rc = sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; - - rc = sqlite3_bind_int64(vm, 4, (sqlite3_int64)offset); + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + *exists = true; + rc = SQLITE_OK; + } else if (rc == SQLITE_DONE) { + *exists = false; + rc = SQLITE_OK; + } + +cleanup: + if (vm) sqlite3_finalize(vm); + return rc; +} + +static void dbmem_database_delete_stale_path (sqlite3 *db, const char *path, uint64_t new_hash) { + if (!path) return; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT hash FROM dbmem_content WHERE path=?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) return; + + sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + uint64_t old_hash = 0; + bool has_old_hash = dbmem_column_hash(vm, 0, &old_hash); + sqlite3_finalize(vm); + if (has_old_hash && old_hash != new_hash) { + dbmem_database_delete_hash(db, old_hash); + } + } else { + sqlite3_finalize(vm); + } +} + +static void dbmem_database_delete_stale_source_path (sqlite3 *db, const char *source_path, uint64_t new_hash) { + if (!source_path) return; + + sqlite3_stmt *vm = NULL; + char *normalized_source_path = dbmem_path_normalized_copy(source_path); + if (!normalized_source_path) return; + + int rc = sqlite3_prepare_v2(db, + "SELECT c.hash FROM dbmem_content c " + "JOIN dbmem_content_source s ON s.path = c.path " + "WHERE s.source_path=?1;", + -1, &vm, NULL); + if (rc != SQLITE_OK) { + dbmemory_free(normalized_source_path); + return; + } + + sqlite3_bind_text(vm, 1, normalized_source_path, -1, SQLITE_STATIC); + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + uint64_t old_hash = 0; + bool has_old_hash = dbmem_column_hash(vm, 0, &old_hash); + sqlite3_finalize(vm); + dbmemory_free(normalized_source_path); + if (has_old_hash && old_hash != new_hash) { + dbmem_database_delete_hash(db, old_hash); + } + } else { + sqlite3_finalize(vm); + dbmemory_free(normalized_source_path); + } +} + +static int dbmem_database_set_source_path (sqlite3 *db, const char *path, const char *source_path) { + if (!path || !path[0] || !source_path || !source_path[0]) return SQLITE_OK; + + sqlite3_stmt *vm = NULL; + char *normalized_source_path = dbmem_path_normalized_copy(source_path); + if (!normalized_source_path) return SQLITE_NOMEM; + + int rc = sqlite3_prepare_v2(db, + "INSERT OR REPLACE INTO dbmem_content_source (path, source_path) VALUES (?1, ?2);", + -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_bind_text(vm, 2, normalized_source_path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + +cleanup: + if (vm) sqlite3_finalize(vm); + dbmemory_free(normalized_source_path); + return rc; +} + +static int dbmem_database_add_entry (dbmem_context *ctx, sqlite3 *db, uint64_t hash, const char *buffer, int64_t len) { + static const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6);"; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto cleanup; + + const char *path = ctx->path; + char uuid[DBMEM_UUID_STR_MAXLEN]; + if (path == NULL) path = dbmem_uuid_v7(uuid); + rc = sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + if (len > (int64_t)INT_MAX) { rc = SQLITE_TOOBIG; goto cleanup; } + rc = (ctx->save_content) ? sqlite3_bind_text(vm, 3, buffer, (int)len, SQLITE_STATIC) : sqlite3_bind_null(vm, 3); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_int64(vm, 4, (sqlite3_int64)len); + if (rc != SQLITE_OK) goto cleanup; + + rc = (ctx->context) ? sqlite3_bind_text(vm, 5, ctx->context, -1, SQLITE_STATIC) : sqlite3_bind_null(vm, 5); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_int64(vm, 6, (sqlite3_int64)time(NULL)); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto cleanup; + + sqlite3_finalize(vm); + vm = NULL; + + rc = dbmem_database_set_source_path(db, path, ctx->source_path); + +cleanup: + if (rc != SQLITE_OK) DEBUG_DBMEM_ALWAYS("Error in dbmem_database_add_entry: %s", sqlite3_errmsg(ctx->db)); + if (vm) sqlite3_finalize(vm); + return rc; +} + +static int dbmem_database_add_directory_marker (dbmem_context *ctx, const char *path, const char *buffer, int64_t len) { + sqlite3 *db = ctx->db; + bool exists = false; + int rc = dbmem_database_path_exists(db, path, &exists); + if (rc != SQLITE_OK) return rc; + if (exists) return SQLITE_OK; + + uint64_t hash = dbmem_storage_hash_compute(buffer, (size_t)len, path, true); + const char *saved_path = ctx->path; + bool saved_save_content = ctx->save_content; + bool transaction_started = false; + + ctx->path = path; + ctx->save_content = true; + rc = dbmem_database_begin_transaction(db); + if (rc != SQLITE_OK) goto cleanup; + transaction_started = true; + + rc = dbmem_database_add_entry(ctx, db, hash, buffer, len); + +cleanup: + if (transaction_started) { + int tx_rc = (rc == SQLITE_OK) ? dbmem_database_commit_transaction(db) : dbmem_database_rollback_transaction(db); + if (rc == SQLITE_OK) rc = tx_rc; + } + ctx->path = saved_path; + ctx->save_content = saved_save_content; + return rc; +} + +static int dbmem_database_add_chunk (dbmem_context *ctx, embedding_result_t *result, size_t offset, size_t length, size_t index) { + static const char *sql = "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length, n_tokens, truncated) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + + rc = dbmem_bind_hash(vm, 1, ctx->hash); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_int64(vm, 2, (sqlite3_int64)index); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_blob(vm, 3, result->embedding, (int)(result->n_embd * sizeof(float)), SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_int64(vm, 4, (sqlite3_int64)offset); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_bind_int64(vm, 5, (sqlite3_int64)length); if (rc != SQLITE_OK) goto cleanup; - + + rc = sqlite3_bind_int(vm, 6, result->n_tokens); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_int(vm, 7, result->truncated ? 1 : 0); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_step(vm); if (rc == SQLITE_DONE) rc = SQLITE_OK; - + cleanup: if (rc != SQLITE_OK) DEBUG_DBMEM_ALWAYS("Error in dbmem_database_add_chunk: %s", sqlite3_errmsg(ctx->db)); if (vm) sqlite3_finalize(vm); @@ -527,32 +1023,53 @@ static int dbmem_database_add_chunk (dbmem_context *ctx, embedding_result_t *res static int dbmem_database_add_fts5 (dbmem_context *ctx, const char *text, size_t text_len, size_t index) { static const char *sql = "INSERT INTO dbmem_vault_fts (content, hash, seq, context) VALUES (?1, ?2, ?3, ?4);"; - + sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_bind_text(vm, 1, text, (int)text_len, SQLITE_STATIC); if (rc != SQLITE_OK) goto cleanup; - + rc = dbmem_bind_hash(vm, 2, ctx->hash); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_bind_int64(vm, 3, (sqlite3_int64)index); if (rc != SQLITE_OK) goto cleanup; - + rc = (ctx->context) ? sqlite3_bind_text(vm, 4, ctx->context, -1, SQLITE_STATIC) : sqlite3_bind_null(vm, 4); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_step(vm); if (rc == SQLITE_DONE) rc = SQLITE_OK; - + cleanup: if (rc != SQLITE_OK) DEBUG_DBMEM_ALWAYS("Error in dbmem_database_add_fts5: %s", sqlite3_errmsg(ctx->db)); if (vm) sqlite3_finalize(vm); return rc; } +static int dbmem_database_add_vault_sentinel (dbmem_context *ctx) { + // a zero-length embedding marks content whose parsing produced no chunks as processed, + // so it is excluded from the pending predicate (sqlite-vector skips undersized blobs during scans) + static const char *sql = "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length, n_tokens, truncated) VALUES (?1, 0, zeroblob(0), 0, 0, 0, 0);"; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = dbmem_bind_hash(vm, 1, ctx->hash); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + +cleanup: + if (rc != SQLITE_OK) DEBUG_DBMEM_ALWAYS("Error in dbmem_database_add_vault_sentinel: %s", sqlite3_errmsg(ctx->db)); + if (vm) sqlite3_finalize(vm); + return rc; +} + static int dbmem_database_begin_transaction (sqlite3 *db) { return sqlite3_exec(db, "SAVEPOINT " DBMEM_SAVEPOINT_NAME ";", NULL, NULL, NULL); } @@ -621,6 +1138,8 @@ static void dbmem_context_reset_temp_values (dbmem_context *ctx) { ctx->hash = 0; ctx->context = NULL; ctx->path = NULL; + ctx->source_path = NULL; + ctx->root_path = NULL; ctx->error_msg[0] = 0; } @@ -633,6 +1152,56 @@ void *dbmem_context_engine (dbmem_context *ctx, bool *is_local) { return (ctx->is_local) ? (void *)ctx->l_engine : (void *)ctx->r_engine; } +int dbmem_context_ensure_engine (dbmem_context *ctx) { + if (!ctx || !ctx->provider || !ctx->model) { + if (ctx) dbmem_context_set_error(ctx, "memory_set_model must be called before adding content"); + return SQLITE_ERROR; + } + + bool is_local_provider = (strcasecmp(ctx->provider, DBMEM_LOCAL_PROVIDER) == 0); + bool is_custom_provider = (ctx->custom_provider_name && ctx->custom_provider.compute && + strcasecmp(ctx->provider, ctx->custom_provider_name) == 0); + + ctx->is_local = is_custom_provider ? false : is_local_provider; + ctx->is_custom = is_custom_provider; + + if (is_custom_provider) { + if (ctx->custom_engine) return SQLITE_OK; + ctx->custom_engine = ctx->custom_provider.init(ctx->model, ctx->api_key, ctx->custom_provider.xdata, ctx->error_msg); + return ctx->custom_engine ? SQLITE_OK : SQLITE_ERROR; + } + + #ifndef DBMEM_OMIT_LOCAL_ENGINE + if (is_local_provider) { + if (ctx->l_engine) return SQLITE_OK; + if (dbmem_file_exists(ctx->model) == false) { + dbmem_context_set_error(ctx, "Local model not found in the specified path"); + return SQLITE_ERROR; + } + + int max_context_tokens = (int)(ctx->max_tokens + ctx->overlay_tokens); + ctx->l_engine = dbmem_local_engine_init(ctx, ctx->model, max_context_tokens, ctx->error_msg); + if (!ctx->l_engine) return SQLITE_ERROR; + if (ctx->engine_warmup) dbmem_local_engine_warmup(ctx->l_engine); + return SQLITE_OK; + } + #else + if (is_local_provider) { + dbmem_context_set_error(ctx, "Local provider cannot be set because SQLite-memory was compiled without local provider support"); + return SQLITE_ERROR; + } + #endif + + #ifndef DBMEM_OMIT_REMOTE_ENGINE + if (ctx->r_engine) return SQLITE_OK; + ctx->r_engine = dbmem_remote_engine_init(ctx, ctx->provider, ctx->model, ctx->error_msg); + return ctx->r_engine ? SQLITE_OK : SQLITE_ERROR; + #else + dbmem_context_set_error(ctx, "Remote provider cannot be set because SQLite-memory was compiled without remote provider support"); + return SQLITE_ERROR; + #endif +} + bool dbmem_context_is_custom (dbmem_context *ctx) { return ctx->is_custom; } @@ -642,7 +1211,7 @@ int dbmem_context_custom_compute (dbmem_context *ctx, const char *text, int text int rc = ctx->custom_provider.compute(ctx->custom_engine, text, text_len, ctx->custom_provider.xdata, &cr); if (rc != 0) return rc; result->n_tokens = cr.n_tokens; - result->n_tokens_truncated = cr.n_tokens_truncated; + result->truncated = cr.truncated; result->n_embd = cr.n_embd; result->embedding = cr.embedding; return 0; @@ -650,9 +1219,9 @@ int dbmem_context_custom_compute (dbmem_context *ctx, const char *text, int text bool dbmem_context_load_vector (dbmem_context *ctx) { if (ctx->vector_extension_available) return true; - + // check if sqlite-vector is loaded - + // there's no built-in way to verify if sqlite-vector has already been already loaded for this specific database connection // the workaround is to attempt to execute vector_version and check for an error // an error indicates that initialization has not been performed @@ -662,10 +1231,10 @@ bool dbmem_context_load_vector (dbmem_context *ctx) { } if (ctx->dimension == 0) { - dbmem_context_set_error(ctx, "SQLite-vector extension cannot be loaded because embedding dimension is not specified"); + dbmem_context_set_error(ctx, "memory_search cannot run because no content has been indexed yet. Add content with memory_add_text(), memory_add_file(), memory_add_content(), or memory_add_directory() before searching."); return false; } - + // In the future can check for quantization options and embedding type here char sql[1024]; snprintf(sql, sizeof(sql), "SELECT vector_init('dbmem_vault', 'embedding', 'type=FLOAT32,distance=COSINE,dimension=%d');", ctx->dimension); @@ -674,14 +1243,14 @@ bool dbmem_context_load_vector (dbmem_context *ctx) { dbmem_context_set_error(ctx, sqlite3_errmsg(ctx->db)); return false; } - + ctx->vector_extension_available = true; return true; } bool dbmem_context_sync_available (dbmem_context *ctx) { if (ctx->sync_extension_available) return true; - + // there's no built-in way to verify if sqlite-sync has already been already loaded for this specific database connection // the workaround is to attempt to execute cloudsync_version and check for an error (an error indicates that initialization has not been performed) if (sqlite3_exec(ctx->db, "SELECT cloudsync_version();", NULL, NULL, NULL) != SQLITE_OK) { @@ -741,6 +1310,22 @@ void dbmem_context_set_errorf (dbmem_context *ctx, const char *fmt, ...) { va_end(ap); } +// MARK: - Status - + +static void dbmem_is_enabled (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); UNUSED_PARAM(argv); + + sqlite3 *db = sqlite3_context_db_handle(context); + int rc = SQLITE_OK; + bool enabled = dbmem_database_is_enabled(db, &rc); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + + sqlite3_result_int(context, enabled ? 1 : 0); +} + // MARK: - Deletion - static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **argv) { @@ -748,7 +1333,7 @@ static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **ar uint64_t hash = 0; if (!dbmem_value_hash(argv[0], &hash)) { - sqlite3_result_error(context, "The function memory_delete expects one argument of type TEXT (hash)", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_delete expects one argument of type TEXT (hash)", -1); return; } sqlite3 *db = sqlite3_context_db_handle(context); @@ -779,6 +1364,13 @@ static void dbmem_delete (sqlite3_context *context, int argc, sqlite3_value **ar sqlite3_finalize(vm); if (rc != SQLITE_DONE) goto rollback; + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content_source WHERE path IN (SELECT path FROM dbmem_content WHERE hash = ?1);", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + dbmem_bind_hash(vm, 1, hash); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + if (rc != SQLITE_DONE) goto rollback; + // Delete from content rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE hash = ?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto rollback; @@ -801,7 +1393,7 @@ static void dbmem_delete_context (sqlite3_context *context, int argc, sqlite3_va UNUSED_PARAM(argc); if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_delete_context expects one argument of type TEXT (context)", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_delete_context expects one argument of type TEXT (context)", -1); return; } @@ -834,6 +1426,13 @@ static void dbmem_delete_context (sqlite3_context *context, int argc, sqlite3_va sqlite3_finalize(vm); if (rc != SQLITE_DONE) goto rollback; + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content_source WHERE path IN (SELECT path FROM dbmem_content WHERE context = ?1);", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + sqlite3_bind_text(vm, 1, ctx_name, -1, SQLITE_STATIC); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + if (rc != SQLITE_DONE) goto rollback; + // Delete from content rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE context = ?1;", -1, &vm, NULL); if (rc != SQLITE_OK) goto rollback; @@ -852,12 +1451,126 @@ static void dbmem_delete_context (sqlite3_context *context, int argc, sqlite3_va sqlite3_result_error(context, sqlite3_errmsg(db), -1); } -static void dbmem_clear (sqlite3_context *context, int argc, sqlite3_value **argv) { - UNUSED_PARAM(argc); UNUSED_PARAM(argv); +static int dbmem_resolve_content_hash_for_path (sqlite3 *db, const char *path, uint64_t *hash, int *matches) { + static const char *sql = + "SELECT c.hash FROM dbmem_content c " + "LEFT JOIN dbmem_content_source s ON s.path = c.path " + "WHERE c.path = ?1 OR c.path = ?2 OR s.source_path = ?3;"; + *matches = 0; + sqlite3_stmt *vm = NULL; + char *marker_path = dbmem_path_directory_marker_storage_copy(path); + if (!marker_path) return SQLITE_NOMEM; + + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_bind_text(vm, 2, marker_path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_bind_text(vm, 3, path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + (*matches)++; + if (*matches == 1 && !dbmem_column_hash(vm, 0, hash)) { + rc = SQLITE_MISMATCH; + break; + } + } + if (rc == SQLITE_DONE) rc = SQLITE_OK; + +cleanup: + sqlite3_finalize(vm); + dbmemory_free(marker_path); + return rc; +} + +static void dbmem_delete_file (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT || sqlite3_value_bytes(argv[0]) == 0) { + sqlite3_result_error(context, "The function memory_delete_file expects one non-empty TEXT argument (path)", -1); + return; + } + + const char *path = (const char *)sqlite3_value_text(argv[0]); sqlite3 *db = sqlite3_context_db_handle(context); + uint64_t hash = 0; + int matches = 0; - int rc = dbmem_database_begin_transaction(db); + int rc = dbmem_resolve_content_hash_for_path(db, path, &hash, &matches); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + if (matches == 0) { + sqlite3_result_int(context, 0); + return; + } + if (matches > 1) { + sqlite3_result_error(context, "memory_delete_file matched more than one row; use a unique logical path or exact local source_path", -1); + return; + } + + rc = dbmem_database_begin_transaction(db); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + + // Delete from FTS first (if available) + if (fts5_is_available) { + sqlite3_stmt *vm = NULL; + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault_fts WHERE hash=?1;", -1, &vm, NULL); + if (rc == SQLITE_OK) { + dbmem_bind_hash(vm, 1, hash); + sqlite3_step(vm); + sqlite3_finalize(vm); + } + } + + // Delete from vault + sqlite3_stmt *vm = NULL; + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_vault WHERE hash=?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + dbmem_bind_hash(vm, 1, hash); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + if (rc != SQLITE_DONE) goto rollback; + + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content_source WHERE path IN (SELECT path FROM dbmem_content WHERE hash=?1);", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + dbmem_bind_hash(vm, 1, hash); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + if (rc != SQLITE_DONE) goto rollback; + + // Delete from content + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_content WHERE hash=?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + dbmem_bind_hash(vm, 1, hash); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + if (rc != SQLITE_DONE) goto rollback; + + int changes = sqlite3_changes(db); + dbmem_database_commit_transaction(db); + sqlite3_result_int(context, changes); + return; + +rollback: + dbmem_database_rollback_transaction(db); + sqlite3_result_error(context, sqlite3_errmsg(db), -1); +} + +static void dbmem_clear (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); UNUSED_PARAM(argv); + + sqlite3 *db = sqlite3_context_db_handle(context); + + int rc = dbmem_database_begin_transaction(db); if (rc != SQLITE_OK) { sqlite3_result_error(context, sqlite3_errmsg(db), -1); return; @@ -873,6 +1586,9 @@ static void dbmem_clear (sqlite3_context *context, int argc, sqlite3_value **arg rc = sqlite3_exec(db, "DELETE FROM dbmem_vault;", NULL, NULL, NULL); if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_exec(db, "DELETE FROM dbmem_content_source;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto rollback; + // Delete from content rc = sqlite3_exec(db, "DELETE FROM dbmem_content;", NULL, NULL, NULL); if (rc != SQLITE_OK) goto rollback; @@ -886,8 +1602,714 @@ static void dbmem_clear (sqlite3_context *context, int argc, sqlite3_value **arg sqlite3_result_error(context, sqlite3_errmsg(db), -1); } +// MARK: - File Rename - + +static void dbmem_rename_file (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT || sqlite3_value_type(argv[1]) != SQLITE_TEXT || + sqlite3_value_bytes(argv[0]) == 0 || sqlite3_value_bytes(argv[1]) == 0) { + sqlite3_result_error(context, "The function memory_rename_file expects two non-empty TEXT arguments (old_path, new_path)", -1); + return; + } + + sqlite3 *db = sqlite3_context_db_handle(context); + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); + const char *old_path = (const char *)sqlite3_value_text(argv[0]); + const char *new_path = (const char *)sqlite3_value_text(argv[1]); + uint64_t hash = 0; + uint64_t new_hash = 0; + int matches = 0; + char *resolved_path = NULL; + char *new_storage_path = NULL; + const char *result_error = NULL; + + int rc = dbmem_resolve_content_hash_for_path(db, old_path, &hash, &matches); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + if (matches == 0) { + sqlite3_result_int(context, 0); + return; + } + if (matches > 1) { + sqlite3_result_error(context, "memory_rename_file matched more than one row; use a unique logical path or exact local source_path", -1); + return; + } + + resolved_path = dbmem_database_path_for_hash_copy(db, hash); + if (!resolved_path) { + sqlite3_result_error_nomem(context); + return; + } + + bool old_is_directory_marker = dbmem_path_is_directory_marker(resolved_path); + bool new_is_directory_marker = dbmem_path_has_trailing_separator(new_path); + if (old_is_directory_marker != new_is_directory_marker) { + sqlite3_result_error(context, old_is_directory_marker + ? "memory_rename_file requires a trailing slash when renaming a directory marker" + : "memory_rename_file cannot rename a file to a directory marker path", -1); + dbmemory_free(resolved_path); + return; + } + + new_storage_path = old_is_directory_marker + ? dbmem_path_directory_marker_storage_copy(new_path) + : dbmem_strdup(new_path); + if (!new_storage_path) { + dbmemory_free(resolved_path); + sqlite3_result_error_nomem(context); + return; + } + + char *conflict_path = old_is_directory_marker + ? dbmem_path_directory_file_storage_copy(new_path) + : dbmem_path_directory_marker_storage_copy(new_path); + if (!conflict_path) { + dbmemory_free(resolved_path); + dbmemory_free(new_storage_path); + sqlite3_result_error_nomem(context); + return; + } + bool conflict = false; + rc = dbmem_database_path_exists(db, conflict_path, &conflict); + dbmemory_free(conflict_path); + if (rc != SQLITE_OK) { + dbmemory_free(resolved_path); + dbmemory_free(new_storage_path); + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + if (conflict) { + dbmemory_free(resolved_path); + dbmemory_free(new_storage_path); + sqlite3_result_error(context, "memory_rename_file path conflicts with an existing file or directory marker", -1); + return; + } + + sqlite3_stmt *vm = NULL; + rc = dbmem_database_begin_transaction(db); + if (rc != SQLITE_OK) goto cleanup; + + new_hash = hash; + if (ctx->preserve_duplicate_paths) { + rc = sqlite3_prepare_v2(db, "SELECT value FROM dbmem_content WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + if (sqlite3_column_type(vm, 0) == SQLITE_NULL) { + result_error = "memory_rename_file cannot rekey preserve_duplicate_paths content when save_content=0"; + sqlite3_finalize(vm); + vm = NULL; + rc = SQLITE_ERROR; + goto rollback; + } + const char *value = (const char *)sqlite3_column_text(vm, 0); + int value_len = sqlite3_column_bytes(vm, 0); + new_hash = dbmem_storage_hash_compute(value, (size_t)value_len, new_storage_path, true); + } + if (rc == SQLITE_ROW || rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + } + + if (new_hash != hash) { + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_vault SET hash = ?2 WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + + if (fts5_is_available) { + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_vault_fts SET hash = ?2 WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + sqlite3_finalize(vm); + vm = NULL; + } + } + + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_content SET hash = ?2, path = ?3 WHERE hash = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 1, hash); + if (rc != SQLITE_OK) goto rollback; + rc = dbmem_bind_hash(vm, 2, new_hash); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_bind_text(vm, 3, new_storage_path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto rollback; + + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + int changes = sqlite3_changes(db); + sqlite3_finalize(vm); + vm = NULL; + + rc = sqlite3_prepare_v2(db, "UPDATE dbmem_content_source SET path = ?2 WHERE path = ?1 OR source_path = ?1;", -1, &vm, NULL); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_bind_text(vm, 1, old_path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_bind_text(vm, 2, new_storage_path, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto rollback; + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto rollback; + + dbmem_database_commit_transaction(db); + if (vm) sqlite3_finalize(vm); + dbmemory_free(resolved_path); + dbmemory_free(new_storage_path); + sqlite3_result_int(context, changes); + return; + +rollback: + dbmem_database_rollback_transaction(db); + +cleanup: + if (vm) sqlite3_finalize(vm); + dbmemory_free(resolved_path); + dbmemory_free(new_storage_path); + sqlite3_result_error(context, result_error ? result_error : sqlite3_errmsg(db), -1); +} + +// MARK: - Path Listing - + +struct dbmem_string_list { + char **items; + int count; + int capacity; +}; + +struct dbmem_json_buffer { + char *data; + size_t length; + size_t capacity; +}; + +static bool dbmem_path_separator (char c) { + return c == '/' || c == '\\'; +} + +static bool dbmem_path_has_trailing_separator (const char *path) { + size_t len = path ? strlen(path) : 0; + return len > 0 && dbmem_path_separator(path[len - 1]); +} + +static bool dbmem_path_is_directory_marker (const char *path) { + return dbmem_path_has_trailing_separator(path); +} + +static bool dbmem_path_char_equal (char a, char b) { + if (dbmem_path_separator(a) && dbmem_path_separator(b)) return true; +#ifdef _WIN32 + if (a >= 'A' && a <= 'Z') a = (char)(a + ('a' - 'A')); + if (b >= 'A' && b <= 'Z') b = (char)(b + ('a' - 'A')); +#endif + return a == b; +} + +static bool dbmem_path_is_absolute (const char *path) { + if (!path || !path[0]) return false; + if (dbmem_path_separator(path[0])) return true; + return (((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && dbmem_path_separator(path[2])); +} + +static void dbmem_string_list_free (dbmem_string_list *list) { + if (!list) return; + for (int i = 0; i < list->count; i++) { + dbmemory_free(list->items[i]); + } + if (list->items) dbmemory_free(list->items); + memset(list, 0, sizeof(*list)); +} + +static int dbmem_string_list_add (dbmem_string_list *list, char *value) { + if (!value) return SQLITE_NOMEM; + + if (list->count >= list->capacity) { + int new_capacity = list->capacity ? list->capacity * 2 : 8; + char **new_items = (char **)dbmemory_realloc(list->items, (uint64_t)new_capacity * sizeof(char *)); + if (!new_items) { + dbmemory_free(value); + return SQLITE_NOMEM; + } + list->items = new_items; + list->capacity = new_capacity; + } + + list->items[list->count++] = value; + return SQLITE_OK; +} + +static size_t dbmem_common_directory_prefix_len (dbmem_string_list *paths) { + if (paths->count == 0) return 0; + + for (int i = 0; i < paths->count; i++) { + if (!dbmem_path_is_absolute(paths->items[i])) return 0; + } + + size_t common_len = strlen(paths->items[0]); + for (int i = 1; i < paths->count; i++) { + size_t j = 0; + while (j < common_len && paths->items[i][j] && paths->items[0][j] == paths->items[i][j]) { + j++; + } + common_len = j; + } + + size_t prefix_len = 0; + for (size_t i = 0; i < common_len; i++) { + if (dbmem_path_separator(paths->items[0][i])) prefix_len = i + 1; + } + + if (paths->count == 1) { + size_t len = strlen(paths->items[0]); + prefix_len = 0; + for (size_t i = 0; i < len; i++) { + if (dbmem_path_separator(paths->items[0][i])) prefix_len = i + 1; + } + } + + return prefix_len; +} + +static char *dbmem_path_copy_normalized (const char *path, size_t prefix_len) { + size_t len = strlen(path); + if (prefix_len > len) prefix_len = 0; + + const char *relative = path + prefix_len; + size_t relative_len = strlen(relative); + char *copy = (char *)dbmemory_alloc((uint64_t)relative_len + 1); + if (!copy) return NULL; + + for (size_t i = 0; i < relative_len; i++) { + copy[i] = dbmem_path_separator(relative[i]) ? '/' : relative[i]; + } + copy[relative_len] = '\0'; + return copy; +} + +static char *dbmem_path_normalized_copy (const char *path) { + if (!path) return NULL; + + size_t len = strlen(path); + char *copy = (char *)dbmemory_alloc((uint64_t)len + 1); + if (!copy) return NULL; + + for (size_t i = 0; i < len; i++) { + copy[i] = dbmem_path_separator(path[i]) ? '/' : path[i]; + } + copy[len] = '\0'; + return copy; +} + +static int dbmem_json_buffer_reserve (dbmem_json_buffer *json, size_t extra) { + if (extra > SIZE_MAX - json->length - 1) return SQLITE_NOMEM; + size_t needed = json->length + extra + 1; + if (needed <= json->capacity) return SQLITE_OK; + + size_t new_capacity = json->capacity ? json->capacity * 2 : 128; + while (new_capacity < needed) { + if (new_capacity > SIZE_MAX / 2) { + new_capacity = needed; + break; + } + new_capacity *= 2; + } + + char *new_data = (char *)dbmemory_realloc(json->data, (uint64_t)new_capacity); + if (!new_data) return SQLITE_NOMEM; + + json->data = new_data; + json->capacity = new_capacity; + return SQLITE_OK; +} + +static int dbmem_json_buffer_append_len (dbmem_json_buffer *json, const char *text, size_t len) { + int rc = dbmem_json_buffer_reserve(json, len); + if (rc != SQLITE_OK) return rc; + + memcpy(json->data + json->length, text, len); + json->length += len; + json->data[json->length] = '\0'; + return SQLITE_OK; +} + +static int dbmem_json_buffer_append (dbmem_json_buffer *json, const char *text) { + return dbmem_json_buffer_append_len(json, text, strlen(text)); +} + +static int dbmem_json_buffer_append_char (dbmem_json_buffer *json, char c) { + return dbmem_json_buffer_append_len(json, &c, 1); +} + +static int dbmem_json_buffer_append_escaped_len (dbmem_json_buffer *json, const char *text, size_t len) { + static const char hex[] = "0123456789abcdef"; + int rc = dbmem_json_buffer_append_char(json, '"'); + if (rc != SQLITE_OK) return rc; + + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)text[i]; + char escaped[6]; + switch (c) { + case '"': rc = dbmem_json_buffer_append(json, "\\\""); break; + case '\\': rc = dbmem_json_buffer_append(json, "\\\\"); break; + case '\b': rc = dbmem_json_buffer_append(json, "\\b"); break; + case '\f': rc = dbmem_json_buffer_append(json, "\\f"); break; + case '\n': rc = dbmem_json_buffer_append(json, "\\n"); break; + case '\r': rc = dbmem_json_buffer_append(json, "\\r"); break; + case '\t': rc = dbmem_json_buffer_append(json, "\\t"); break; + default: + if (c < 0x20) { + escaped[0] = '\\'; + escaped[1] = 'u'; + escaped[2] = '0'; + escaped[3] = '0'; + escaped[4] = hex[c >> 4]; + escaped[5] = hex[c & 0x0f]; + rc = dbmem_json_buffer_append_len(json, escaped, sizeof(escaped)); + } else { + rc = dbmem_json_buffer_append_char(json, (char)c); + } + break; + } + if (rc != SQLITE_OK) return rc; + } + + return dbmem_json_buffer_append_char(json, '"'); +} + +static int dbmem_json_buffer_append_escaped (dbmem_json_buffer *json, const char *text) { + return dbmem_json_buffer_append_escaped_len(json, text, strlen(text)); +} + +static size_t dbmem_path_segment_start (const char *path, size_t offset) { + while (path[offset] == '/') offset++; + return offset; +} + +static size_t dbmem_path_segment_end (const char *path, size_t start) { + while (path[start] && path[start] != '/') start++; + return start; +} + +static bool dbmem_path_has_more_segments (const char *path, size_t segment_end) { + return path[dbmem_path_segment_start(path, segment_end)] != '\0'; +} + +static int dbmem_segment_compare (const char *a, size_t a_len, const char *b, size_t b_len) { + size_t min_len = (a_len < b_len) ? a_len : b_len; + int cmp = memcmp(a, b, min_len); + if (cmp != 0) return cmp; + if (a_len == b_len) return 0; + return (a_len < b_len) ? -1 : 1; +} + +static bool dbmem_same_segment (const char *a, size_t a_start, size_t a_end, const char *b, size_t b_start, size_t b_end) { + size_t a_len = a_end - a_start; + size_t b_len = b_end - b_start; + return a_len == b_len && memcmp(a + a_start, b + b_start, a_len) == 0; +} + +static int dbmem_path_tree_compare (const void *a, const void *b) { + const char *pa = *(const char * const *)a; + const char *pb = *(const char * const *)b; + size_t ia = 0; + size_t ib = 0; + + while (true) { + ia = dbmem_path_segment_start(pa, ia); + ib = dbmem_path_segment_start(pb, ib); + + size_t ea = dbmem_path_segment_end(pa, ia); + size_t eb = dbmem_path_segment_end(pb, ib); + bool enda = ia == ea; + bool endb = ib == eb; + if (enda || endb) { + if (enda == endb) return 0; + return enda ? -1 : 1; + } + + int cmp = dbmem_segment_compare(pa + ia, ea - ia, pb + ib, eb - ib); + if (cmp != 0) return cmp; + + bool morea = dbmem_path_has_more_segments(pa, ea); + bool moreb = dbmem_path_has_more_segments(pb, eb); + if (!morea || !moreb) { + if (morea == moreb) return 0; + return morea ? -1 : 1; + } + + ia = ea; + ib = eb; + } +} + +static int dbmem_path_group_end (dbmem_string_list *paths, int start, int end, size_t offset) { + const char *first = paths->items[start]; + size_t first_start = dbmem_path_segment_start(first, offset); + size_t first_end = dbmem_path_segment_end(first, first_start); + int i = start + 1; + + while (i < end) { + const char *path = paths->items[i]; + size_t segment_start = dbmem_path_segment_start(path, offset); + size_t segment_end = dbmem_path_segment_end(path, segment_start); + if (!dbmem_same_segment(first, first_start, first_end, path, segment_start, segment_end)) break; + i++; + } + + return i; +} + +static bool dbmem_path_group_has_child (dbmem_string_list *paths, int start, int end, size_t offset) { + for (int i = start; i < end; i++) { + const char *path = paths->items[i]; + size_t segment_start = dbmem_path_segment_start(path, offset); + size_t segment_end = dbmem_path_segment_end(path, segment_start); + if (segment_start != segment_end && + (dbmem_path_has_more_segments(path, segment_end) || dbmem_path_is_directory_marker(path))) return true; + } + return false; +} + +static int dbmem_path_group_file_index (dbmem_string_list *paths, int start, int end, size_t offset) { + for (int i = start; i < end; i++) { + const char *path = paths->items[i]; + size_t segment_start = dbmem_path_segment_start(path, offset); + size_t segment_end = dbmem_path_segment_end(path, segment_start); + if (segment_start != segment_end && !dbmem_path_has_more_segments(path, segment_end) && !dbmem_path_is_directory_marker(path)) return i; + } + return -1; +} + +static int dbmem_json_append_file_node (dbmem_json_buffer *json, const char *path, bool indexed, size_t segment_start, size_t segment_end) { + int rc = dbmem_json_buffer_append(json, "{\"type\":\"file\",\"name\":"); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append_escaped_len(json, path + segment_start, segment_end - segment_start); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append(json, ",\"path\":"); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append_escaped(json, path); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append(json, indexed ? ",\"indexed\":true" : ",\"indexed\":false"); + if (rc != SQLITE_OK) return rc; + return dbmem_json_buffer_append_char(json, '}'); +} + +static int dbmem_json_append_directory_node (dbmem_json_buffer *json, dbmem_string_list *paths, const unsigned char *flags, int start, int end, size_t offset) { + const char *path = paths->items[start]; + size_t segment_start = dbmem_path_segment_start(path, offset); + size_t segment_end = dbmem_path_segment_end(path, segment_start); + + int rc = dbmem_json_buffer_append(json, "{\"type\":\"directory\",\"name\":"); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append_escaped_len(json, path + segment_start, segment_end - segment_start); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append(json, ",\"path\":"); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append_escaped_len(json, path, segment_end); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_buffer_append(json, ",\"children\":"); + if (rc != SQLITE_OK) return rc; + rc = dbmem_json_append_tree_children(json, paths, flags, start, end, segment_end); + if (rc != SQLITE_OK) return rc; + return dbmem_json_buffer_append_char(json, '}'); +} + +static int dbmem_json_append_tree_children (dbmem_json_buffer *json, dbmem_string_list *paths, const unsigned char *flags, int start, int end, size_t offset) { + int rc = dbmem_json_buffer_append_char(json, '['); + if (rc != SQLITE_OK) return rc; + + bool first = true; + for (int pass = 0; pass < 2; pass++) { + int i = start; + while (i < end) { + const char *path = paths->items[i]; + size_t segment_start = dbmem_path_segment_start(path, offset); + if (path[segment_start] == '\0') { + i++; + continue; + } + + int group_end = dbmem_path_group_end(paths, i, end, offset); + bool emit_directory = (pass == 0 && dbmem_path_group_has_child(paths, i, group_end, offset)); + int file_index = (pass == 1) ? dbmem_path_group_file_index(paths, i, group_end, offset) : -1; + bool emit_file = file_index >= 0; + + if (emit_directory || emit_file) { + if (!first) { + rc = dbmem_json_buffer_append_char(json, ','); + if (rc != SQLITE_OK) return rc; + } + first = false; + + if (emit_directory) { + rc = dbmem_json_append_directory_node(json, paths, flags, i, group_end, offset); + } else { + const char *file_path = paths->items[file_index]; + bool indexed = flags ? (flags[file_index] != 0) : true; + segment_start = dbmem_path_segment_start(file_path, offset); + size_t segment_end = dbmem_path_segment_end(file_path, segment_start); + rc = dbmem_json_append_file_node(json, file_path, indexed, segment_start, segment_end); + } + if (rc != SQLITE_OK) return rc; + } + + i = group_end; + } + } + + return dbmem_json_buffer_append_char(json, ']'); +} + +typedef struct { + char *path; + unsigned char indexed; +} dbmem_path_entry; + +static int dbmem_path_entry_compare (const void *a, const void *b) { + const dbmem_path_entry *ea = (const dbmem_path_entry *)a; + const dbmem_path_entry *eb = (const dbmem_path_entry *)b; + return dbmem_path_tree_compare(&ea->path, &eb->path); +} + +static int dbmem_paths_to_json (dbmem_string_list *paths, unsigned char *flags, char **result) { + dbmem_json_buffer json = {0}; + int rc = SQLITE_OK; + size_t prefix_len = dbmem_common_directory_prefix_len(paths); + + for (int i = 0; i < paths->count; i++) { + char *normalized = dbmem_path_copy_normalized(paths->items[i], prefix_len); + if (!normalized) { rc = SQLITE_NOMEM; goto cleanup; } + + dbmemory_free(paths->items[i]); + paths->items[i] = normalized; + } + + if (paths->count > 1) { + // sort paths and indexed flags together so flags keep matching their path by index + dbmem_path_entry *entries = (dbmem_path_entry *)dbmemory_alloc((uint64_t)paths->count * sizeof(dbmem_path_entry)); + if (!entries) { rc = SQLITE_NOMEM; goto cleanup; } + for (int i = 0; i < paths->count; i++) { + entries[i].path = paths->items[i]; + entries[i].indexed = flags ? flags[i] : 1; + } + qsort(entries, (size_t)paths->count, sizeof(dbmem_path_entry), dbmem_path_entry_compare); + for (int i = 0; i < paths->count; i++) { + paths->items[i] = entries[i].path; + if (flags) flags[i] = entries[i].indexed; + } + dbmemory_free(entries); + } + + rc = dbmem_json_buffer_append(&json, "{\"root\":\"\",\"children\":"); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_json_append_tree_children(&json, paths, flags, 0, paths->count, 0); + if (rc != SQLITE_OK) goto cleanup; + rc = dbmem_json_buffer_append_char(&json, '}'); + if (rc != SQLITE_OK) goto cleanup; + + *result = json.data; + json.data = NULL; + +cleanup: + if (json.data) dbmemory_free(json.data); + return rc; +} + +static void dbmem_list_files (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); UNUSED_PARAM(argv); + + sqlite3 *db = sqlite3_context_db_handle(context); + sqlite3_stmt *vm = NULL; + dbmem_string_list paths = {0}; + unsigned char *flags = NULL; + int flags_capacity = 0; + char *json = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT path, (length = 0 OR EXISTS (SELECT 1 FROM dbmem_vault v WHERE v.hash = dbmem_content.hash)) " + "FROM dbmem_content WHERE path IS NOT NULL AND path != '';", + -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + const char *path = (const char *)sqlite3_column_text(vm, 0); + char *copy = dbmem_strdup(path); + rc = dbmem_string_list_add(&paths, copy); + if (rc != SQLITE_OK) goto cleanup; + + if (paths.count > flags_capacity) { + int new_capacity = flags_capacity ? flags_capacity * 2 : 8; + unsigned char *new_flags = (unsigned char *)dbmemory_realloc(flags, (uint64_t)new_capacity); + if (!new_flags) { rc = SQLITE_NOMEM; goto cleanup; } + flags = new_flags; + flags_capacity = new_capacity; + } + flags[paths.count - 1] = (unsigned char)(sqlite3_column_int(vm, 1) != 0); + } + if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc != SQLITE_OK) goto cleanup; + + rc = dbmem_paths_to_json(&paths, flags, &json); + +cleanup: + if (vm) sqlite3_finalize(vm); + dbmem_string_list_free(&paths); + if (flags) dbmemory_free(flags); + + if (rc == SQLITE_OK) { + sqlite3_result_text(context, json ? json : "{\"root\":\"\",\"children\":[]}", -1, json ? dbmemory_free : SQLITE_TRANSIENT); + } else if (rc == SQLITE_NOMEM) { + if (json) dbmemory_free(json); + sqlite3_result_error_nomem(context); + } else { + if (json) dbmemory_free(json); + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + } +} + // MARK: - Cache Clear - +static int dbmem_cache_clear_provider_model(sqlite3 *db, const char *provider, const char *model) { + static const char *sql = "DELETE FROM dbmem_cache WHERE provider=?1 AND model=?2;"; + if (!provider || !model) return SQLITE_OK; + + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_text(vm, 1, provider, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_bind_text(vm, 2, model, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; + +cleanup: + if (vm) sqlite3_finalize(vm); + return rc; +} + static void dbmem_cache_clear (sqlite3_context *context, int argc, sqlite3_value **argv) { sqlite3 *db = sqlite3_context_db_handle(context); int rc; @@ -896,23 +2318,15 @@ static void dbmem_cache_clear (sqlite3_context *context, int argc, sqlite3_value rc = sqlite3_exec(db, "DELETE FROM dbmem_cache;", NULL, NULL, NULL); } else if (argc == 2) { if (sqlite3_value_type(argv[0]) != SQLITE_TEXT || sqlite3_value_type(argv[1]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_cache_clear expects two arguments of type TEXT (provider, model)", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_cache_clear expects two arguments of type TEXT (provider, model)", -1); return; } const char *provider = (const char *)sqlite3_value_text(argv[0]); const char *model = (const char *)sqlite3_value_text(argv[1]); - sqlite3_stmt *vm = NULL; - rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_cache WHERE provider=?1 AND model=?2;", -1, &vm, NULL); - if (rc == SQLITE_OK) { - sqlite3_bind_text(vm, 1, provider, -1, SQLITE_STATIC); - sqlite3_bind_text(vm, 2, model, -1, SQLITE_STATIC); - rc = sqlite3_step(vm); - if (rc == SQLITE_DONE) rc = SQLITE_OK; - } - if (vm) sqlite3_finalize(vm); + rc = dbmem_cache_clear_provider_model(db, provider, model); } else { - sqlite3_result_error(context, "The function memory_cache_clear expects 0 or 2 arguments", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_cache_clear expects 0 or 2 arguments", -1); return; } @@ -931,24 +2345,22 @@ static void dbmem_version (sqlite3_context *context, int argc, sqlite3_value **a sqlite3_result_text(context, SQLITE_DBMEMORY_VERSION, -1, NULL); } -static int dbmem_reindex(dbmem_context *ctx); - static void dbmem_set_model (sqlite3_context *context, int argc, sqlite3_value **argv) { // 2 TEXT arguments: provider and model - + // if provider is local then model is the full path to the model to use // options are saved into settings - + // sanity check type if ((sqlite3_value_type(argv[0]) != SQLITE_TEXT) || (sqlite3_value_type(argv[1]) != SQLITE_TEXT)) { - sqlite3_result_error(context, "The function memory_set_model expects two arguments of type TEXT", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_set_model expects two arguments of type TEXT", -1); return; } - + // retrieve arguments const char *provider = (const char *)sqlite3_value_text(argv[0]); const char *model = (const char *)sqlite3_value_text(argv[1]); - + // retrieve context dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); sqlite3 *db = sqlite3_context_db_handle(context); @@ -968,13 +2380,13 @@ static void dbmem_set_model (sqlite3_context *context, int argc, sqlite3_value * if (!is_custom_provider) { #ifdef DBMEM_OMIT_LOCAL_ENGINE if (is_local_provider) { - sqlite3_result_error(context, "Local provider cannot be set because SQLite-memory was compiled without local provider support", SQLITE_ERROR); + sqlite3_result_error(context, "Local provider cannot be set because SQLite-memory was compiled without local provider support", -1); return; } #endif #ifdef DBMEM_OMIT_REMOTE_ENGINE if (!is_local_provider) { - sqlite3_result_error(context, "Remote provider cannot be set because SQLite-memory was compiled without remote provider support", SQLITE_ERROR); + sqlite3_result_error(context, "Remote provider cannot be set because SQLite-memory was compiled without remote provider support", -1); return; } #endif @@ -1026,11 +2438,12 @@ static void dbmem_set_model (sqlite3_context *context, int argc, sqlite3_value * if (dbmem_file_exists(model) == false) { dbmemory_free(new_provider); dbmemory_free(new_model); - sqlite3_result_error(context, "Local model not found in the specified path", SQLITE_ERROR); + sqlite3_result_error(context, "Local model not found in the specified path", -1); return; } - new_l_engine = dbmem_local_engine_init(ctx, model, ctx->error_msg); + int max_context_tokens = (int)(ctx->max_tokens + ctx->overlay_tokens); + new_l_engine = dbmem_local_engine_init(ctx, model, max_context_tokens, ctx->error_msg); if (new_l_engine == NULL) { dbmemory_free(new_provider); dbmemory_free(new_model); @@ -1156,16 +2569,16 @@ static void dbmem_set_model (sqlite3_context *context, int argc, sqlite3_value * static void dbmem_set_apikey (sqlite3_context *context, int argc, sqlite3_value **argv) { // sanity check type if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_set_apikey expects one argument of type TEXT", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_set_apikey expects one argument of type TEXT", -1); return; } - + char *apikey = dbmem_strdup((const char *)sqlite3_value_text(argv[0])); if (!apikey) { sqlite3_result_error_nomem(context); return; } - + // retrieve context dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); @@ -1182,62 +2595,135 @@ static void dbmem_set_apikey (sqlite3_context *context, int argc, sqlite3_value if (ctx->api_key) dbmemory_free(ctx->api_key); ctx->api_key = apikey; - + sqlite3_result_int(context, 1); } // MARK: - +static bool dbmem_is_local_context_option(const char *key) { + return (strcasecmp(key, DBMEM_SETTINGS_KEY_MAX_TOKENS) == 0 || + strcasecmp(key, DBMEM_SETTINGS_KEY_OVERLAY_TOKENS) == 0); +} + +static int dbmem_rebuild_local_engine_for_context_options(dbmem_context *ctx) { + #ifndef DBMEM_OMIT_LOCAL_ENGINE + if (!ctx || !ctx->is_local || ctx->is_custom || !ctx->model || !ctx->l_engine) { + return SQLITE_OK; + } + + int max_context_tokens = (int)(ctx->max_tokens + ctx->overlay_tokens); + dbmem_local_engine_t *new_l_engine = dbmem_local_engine_init(ctx, ctx->model, max_context_tokens, ctx->error_msg); + if (new_l_engine == NULL) { + return SQLITE_ERROR; + } + + if (ctx->engine_warmup) { + dbmem_local_engine_warmup(new_l_engine); + } + + int rc = dbmem_cache_clear_provider_model(ctx->db, ctx->provider, ctx->model); + if (rc != SQLITE_OK) { + dbmem_local_engine_free(new_l_engine); + return rc; + } + + dbmem_local_engine_free(ctx->l_engine); + ctx->l_engine = new_l_engine; + #else + UNUSED_PARAM(ctx); + #endif + + return SQLITE_OK; +} + static void dbmem_set_option (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); + // sanity check type if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_set_option expects the key argument to be of type TEXT", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_set_option expects the key argument to be of type TEXT", -1); return; } - - // update settings + sqlite3 *db = sqlite3_context_db_handle(context); const char *key = (const char *)sqlite3_value_text(argv[0]); - int rc = dbmem_settings_write_value(db, key, argv[1]); - + // retrieve context dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); - + ctx->error_msg[0] = 0; + + bool context_option = dbmem_is_local_context_option(key); + size_t old_max_tokens = ctx->max_tokens; + size_t old_overlay_tokens = ctx->overlay_tokens; + + int rc = sqlite3_exec(db, "SAVEPOINT dbmem_set_option;", NULL, NULL, NULL); + bool savepoint_started = (rc == SQLITE_OK); + + if (rc == SQLITE_OK) { + rc = dbmem_settings_write_value(db, key, argv[1]); + } + if (rc == SQLITE_OK) { dbmem_settings_sync(ctx, key, argv[1]); } - - (rc == SQLITE_OK) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, sqlite3_errmsg(db), -1); + + if (rc == SQLITE_OK && context_option && + (old_max_tokens != ctx->max_tokens || old_overlay_tokens != ctx->overlay_tokens)) { + rc = dbmem_rebuild_local_engine_for_context_options(ctx); + } + + if (rc == SQLITE_OK && savepoint_started) { + rc = sqlite3_exec(db, "RELEASE dbmem_set_option;", NULL, NULL, NULL); + savepoint_started = false; + } + + if (rc != SQLITE_OK) { + if (savepoint_started) { + sqlite3_exec(db, "ROLLBACK TO dbmem_set_option; RELEASE dbmem_set_option;", NULL, NULL, NULL); + } + if (context_option) { + ctx->max_tokens = old_max_tokens; + ctx->overlay_tokens = old_overlay_tokens; + } + } + + (rc == SQLITE_OK) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg[0] ? ctx->error_msg : sqlite3_errmsg(db), -1); } static void dbmem_get_option (sqlite3_context *context, int argc, sqlite3_value **argv) { static const char *sql = "SELECT value FROM dbmem_settings WHERE key=?1 LIMIT 1;"; - + // sanity check type if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_get_option expects the key argument to be of type TEXT", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_get_option expects the key argument to be of type TEXT", -1); return; } - + // retrieve from settings sqlite3_stmt *vm = NULL; sqlite3 *db = sqlite3_context_db_handle(context); int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - + const char *key = (const char *)sqlite3_value_text(argv[0]); rc = sqlite3_bind_text(vm, 1, key, -1, NULL); if (rc != SQLITE_OK) goto cleanup; - + rc = sqlite3_step(vm); if (rc == SQLITE_DONE) { - sqlite3_result_null(context); + if (strcasecmp(key, DBMEM_SETTINGS_KEY_PRESERVE_DUP_PATHS) == 0 || + strcasecmp(key, DBMEM_SETTINGS_KEY_DEFER_EMBEDDINGS) == 0) { + sqlite3_result_int(context, 0); + } else { + sqlite3_result_null(context); + } rc = SQLITE_OK; } else if (rc == SQLITE_ROW) { sqlite3_result_value(context, sqlite3_column_value(vm, 0)); rc = SQLITE_OK; } - + cleanup: if (vm) sqlite3_finalize(vm); if (rc != SQLITE_OK) sqlite3_result_error(context, sqlite3_errmsg(db), -1); @@ -1249,7 +2735,7 @@ static void dbmem_get_option (sqlite3_context *context, int argc, sqlite3_value static void dbmem_dump_embeding (const embedding_result_t *result) { printf("{\n"); printf(" \"n_tokens\": %d,\n", result->n_tokens); - printf(" \"n_tokens_truncated\": %d,\n", result->n_tokens_truncated); + printf(" \"truncated\": %s,\n", result->truncated ? "true" : "false"); printf(" \"n_embd\": %d,\n", result->n_embd); printf(" \"embedding\": ["); @@ -1267,7 +2753,7 @@ static void dbmem_dump_embeding (const embedding_result_t *result) { // MARK: - Embedding Cache - static bool dbmem_cache_lookup (dbmem_context *ctx, uint64_t text_hash, embedding_result_t *result) { - static const char *sql = "SELECT embedding, dimension FROM dbmem_cache WHERE text_hash=?1 AND provider=?2 AND model=?3 LIMIT 1;"; + static const char *sql = "SELECT embedding, dimension, n_tokens, truncated FROM dbmem_cache WHERE text_hash=?1 AND provider=?2 AND model=?3 LIMIT 1;"; if (!ctx->provider || !ctx->model) return false; @@ -1300,8 +2786,8 @@ static bool dbmem_cache_lookup (dbmem_context *ctx, uint64_t text_hash, embeddin memcpy(ctx->cache_buffer, blob, blob_bytes); result->embedding = ctx->cache_buffer; result->n_embd = dimension; - result->n_tokens = 0; - result->n_tokens_truncated = 0; + result->n_tokens = sqlite3_column_int(vm, 2); + result->truncated = sqlite3_column_int(vm, 3) != 0; found = true; cleanup: @@ -1337,7 +2823,7 @@ static void dbmem_cache_evict (dbmem_context *ctx) { } static void dbmem_cache_store (dbmem_context *ctx, uint64_t text_hash, const embedding_result_t *result) { - static const char *sql = "INSERT OR REPLACE INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES (?1, ?2, ?3, ?4, ?5);"; + static const char *sql = "INSERT OR REPLACE INTO dbmem_cache (text_hash, provider, model, embedding, dimension, n_tokens, truncated) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"; if (!ctx->provider || !ctx->model) return; @@ -1350,6 +2836,8 @@ static void dbmem_cache_store (dbmem_context *ctx, uint64_t text_hash, const emb sqlite3_bind_text(vm, 3, ctx->model, -1, SQLITE_STATIC); sqlite3_bind_blob(vm, 4, result->embedding, result->n_embd * (int)sizeof(float), SQLITE_STATIC); sqlite3_bind_int(vm, 5, result->n_embd); + sqlite3_bind_int(vm, 6, result->n_tokens); + sqlite3_bind_int(vm, 7, result->truncated ? 1 : 0); sqlite3_step(vm); @@ -1378,14 +2866,29 @@ static int dbmem_process_callback (const char *text, size_t len, size_t offset, } if (!cache_hit) { + if (!ctx->provider || !ctx->model) { + dbmem_context_set_error(ctx, "memory_set_model must be called before adding content"); + return SQLITE_ERROR; + } + rc = dbmem_context_ensure_engine(ctx); + if (rc != SQLITE_OK) return rc; + // compute embedding if (ctx->is_custom) { + if (!ctx->custom_engine || !ctx->custom_provider.compute) { + dbmem_context_set_error(ctx, "memory_set_model must be called before adding content"); + return SQLITE_ERROR; + } rc = dbmem_context_custom_compute(ctx, text, (int)len, &result); if (rc != 0) return rc; } else if (ctx->is_local) { #ifndef DBMEM_OMIT_LOCAL_ENGINE + if (!ctx->l_engine) { + dbmem_context_set_error(ctx, "memory_set_model must be called before adding content"); + return SQLITE_ERROR; + } rc = dbmem_local_compute_embedding(ctx->l_engine, text, (int)len, &result); if (rc != 0) return rc; #else @@ -1396,6 +2899,10 @@ static int dbmem_process_callback (const char *text, size_t len, size_t offset, else { #ifndef DBMEM_OMIT_REMOTE_ENGINE + if (!ctx->r_engine) { + dbmem_context_set_error(ctx, "memory_set_model must be called before adding content"); + return SQLITE_ERROR; + } rc = dbmem_remote_compute_embedding(ctx->r_engine, text, (int)len, &result); if (rc != 0) return rc; #else @@ -1409,7 +2916,7 @@ static int dbmem_process_callback (const char *text, size_t len, size_t offset, dbmem_cache_store(ctx, chunk_hash, &result); } } - + // make sure dimension is the same if (ctx->dimension == 0) ctx->dimension = result.n_embd; else if (ctx->dimension != result.n_embd) { @@ -1423,6 +2930,7 @@ static int dbmem_process_callback (const char *text, size_t len, size_t offset, dbmem_context_set_error(ctx, sqlite3_errmsg(ctx->db)); goto cleanup; } + ctx->chunks_added++; DEBUG_EMBEDDING(&result); // save FTS5 (if available) @@ -1432,17 +2940,52 @@ static int dbmem_process_callback (const char *text, size_t len, size_t offset, dbmem_context_set_error(ctx, sqlite3_errmsg(ctx->db)); goto cleanup; } - + cleanup: return rc; } static int dbmem_process_buffer (dbmem_context *ctx, const char *buffer, int64_t len) { - uint64_t hash = dbmem_hash_compute(buffer, (size_t)len); + if (ctx->defer_embeddings && !ctx->reindex_mode && !ctx->save_content) { + dbmem_context_set_error(ctx, "defer_embeddings requires save_content to be enabled"); + return SQLITE_ERROR; + } + + uint64_t hash = dbmem_storage_hash_compute(buffer, (size_t)len, ctx->path, ctx->preserve_duplicate_paths); + const char *saved_path = ctx->path; + char *unique_path = NULL; + bool transaction_started = false; + + if (!ctx->reindex_mode && ctx->path) { + unique_path = dbmem_path_unique_storage_copy(ctx->db, ctx->path, ctx->source_path); + if (!unique_path) return SQLITE_NOMEM; + ctx->path = unique_path; + hash = dbmem_storage_hash_compute(buffer, (size_t)len, ctx->path, ctx->preserve_duplicate_paths); + } + + sqlite3 *db = ctx->db; + int rc = dbmem_database_begin_transaction(db); + if (rc != SQLITE_OK) goto cleanup; + transaction_started = true; - // In normal mode: skip if already indexed if (!ctx->reindex_mode) { - if (dbmem_database_check_if_stored(ctx->db, hash, len)) return SQLITE_OK; + if (ctx->source_path) { + dbmem_database_delete_stale_source_path(db, ctx->source_path, hash); + } + dbmem_database_delete_stale_path(db, ctx->path, hash); + + if (dbmem_database_check_if_stored(ctx->db, hash, len)) { + if (ctx->source_path) { + char *stored_path = dbmem_database_path_for_hash_copy(ctx->db, hash); + if (!stored_path) { + rc = SQLITE_NOMEM; + goto cleanup; + } + rc = dbmem_database_set_source_path(ctx->db, stored_path, ctx->source_path); + dbmemory_free(stored_path); + } + goto cleanup; + } } // set up parse settings @@ -1456,31 +2999,271 @@ static int dbmem_process_buffer (dbmem_context *ctx, const char *buffer, int64_t settings.overlay_tokens = ctx->overlay_tokens; settings.skip_semantic = ctx->skip_semantic; settings.skip_html = ctx->skip_html; - - sqlite3 *db = ctx->db; - int rc = dbmem_database_begin_transaction(db); - if (rc != SQLITE_OK) goto cleanup; + settings.mdx_mode = (ctx->path && dbmem_file_has_extension(ctx->path, "mdx")); if (!ctx->reindex_mode) { - // delete old entry if this path was previously indexed with different content - dbmem_database_delete_stale_path(db, ctx->path, hash); rc = dbmem_database_add_entry(ctx, db, hash, buffer, len); if (rc != SQLITE_OK) goto cleanup; } + if (len == 0) goto cleanup; + if (ctx->defer_embeddings && !ctx->reindex_mode) goto cleanup; + + ctx->chunks_added = 0; rc = dbmem_parse(buffer, (size_t)len, &settings); - - if (rc == SQLITE_OK && !ctx->dimension_saved) { + + if (rc == SQLITE_OK && ctx->chunks_added == 0) { + rc = dbmem_database_add_vault_sentinel(ctx); + } + + // persist the dimension only after a real embedding established it: + // a zero-chunk parse would latch dimension=0 and block the real write + if (rc == SQLITE_OK && ctx->chunks_added > 0 && !ctx->dimension_saved) { // make sure to serialize dimension dbmem_settings_write_int(db, DBMEM_SETTINGS_KEY_DIMENSION, ctx->dimension); ctx->dimension_saved = true; } - + cleanup: - (rc == SQLITE_OK) ? dbmem_database_commit_transaction(db) : dbmem_database_rollback_transaction(db); + if (transaction_started) { + int tx_rc = (rc == SQLITE_OK) ? dbmem_database_commit_transaction(db) : dbmem_database_rollback_transaction(db); + if (rc == SQLITE_OK) rc = tx_rc; + } + ctx->path = saved_path; + if (unique_path) dbmemory_free(unique_path); return rc; } +static size_t dbmem_path_trimmed_len (const char *path) { + size_t len = path ? strlen(path) : 0; + while (len > 0 && dbmem_path_separator(path[len - 1])) len--; + return len; +} + +static const char *dbmem_path_basename_ptr (const char *path, size_t len) { + if (!path) return NULL; + while (len > 0 && dbmem_path_separator(path[len - 1])) len--; + for (size_t i = len; i > 0; i--) { + if (dbmem_path_separator(path[i - 1])) return path + i; + } + return path; +} + +static char *dbmem_path_suffix_copy (const char *path, int components) { + if (!path || components <= 0) return NULL; + + size_t end = dbmem_path_trimmed_len(path); + size_t start = end; + int found = 0; + + while (start > 0 && found < components) { + while (start > 0 && dbmem_path_separator(path[start - 1])) start--; + while (start > 0 && !dbmem_path_separator(path[start - 1])) start--; + found++; + } + + while (start < end && dbmem_path_separator(path[start])) start++; + if (start >= end) return NULL; + + size_t len = end - start; + char *copy = (char *)dbmemory_alloc((uint64_t)len + 1); + if (!copy) return NULL; + for (size_t i = 0; i < len; i++) { + copy[i] = dbmem_path_separator(path[start + i]) ? '/' : path[start + i]; + } + copy[len] = '\0'; + return copy; +} + +static int dbmem_path_component_count (const char *path) { + if (!path) return 0; + + size_t len = dbmem_path_trimmed_len(path); + size_t i = 0; + int count = 0; + + while (i < len) { + while (i < len && dbmem_path_separator(path[i])) i++; + if (i >= len) break; + count++; + while (i < len && !dbmem_path_separator(path[i])) i++; + } + + return count; +} + +static const char *dbmem_path_relative_ptr (const char *path, const char *root, size_t *out_len) { + size_t path_len = dbmem_path_trimmed_len(path); + if (out_len) *out_len = path_len; + if (!path) return NULL; + + if (root && root[0]) { + size_t root_len = dbmem_path_trimmed_len(root); + if (root_len > 0 && strncmp(path, root, root_len) == 0 && + (path[root_len] == '\0' || dbmem_path_separator(path[root_len]))) { + const char *relative = path + root_len; + if (dbmem_path_separator(*relative)) relative++; + if (out_len) *out_len = path_len - (size_t)(relative - path); + return relative; + } + } + + if (dbmem_path_is_absolute(path)) { + const char *base = dbmem_path_basename_ptr(path, path_len); + if (out_len) *out_len = path_len - (size_t)(base - path); + return base; + } + + return path; +} + +static char *dbmem_path_storage_copy (const char *path, const char *root) { + if (path && (!root || !root[0]) && dbmem_path_is_absolute(path)) { + char *suffix = dbmem_path_suffix_copy(path, 2); + if (suffix) return suffix; + } + + size_t len = 0; + const char *relative = dbmem_path_relative_ptr(path, root, &len); + if (!relative) return NULL; + + while (len > 0 && dbmem_path_separator(*relative)) { + relative++; + len--; + } + while (len > 0 && dbmem_path_separator(relative[len - 1])) len--; + + if (len == 0 && path) { + relative = dbmem_path_basename_ptr(path, dbmem_path_trimmed_len(path)); + len = strlen(relative); + } + + char *copy = (char *)dbmemory_alloc((uint64_t)len + 1); + if (!copy) return NULL; + for (size_t i = 0; i < len; i++) { + copy[i] = dbmem_path_separator(relative[i]) ? '/' : relative[i]; + } + copy[len] = '\0'; + return copy; +} + +static char *dbmem_path_directory_marker_storage_copy (const char *path) { + char *base = dbmem_path_storage_copy(path, NULL); + if (!base) return NULL; + size_t len = strlen(base); + if (len == 0) return base; + + char *copy = (char *)dbmemory_alloc((uint64_t)len + 2); + if (!copy) { + dbmemory_free(base); + return NULL; + } + memcpy(copy, base, len); + copy[len] = '/'; + copy[len + 1] = '\0'; + dbmemory_free(base); + return copy; +} + +static char *dbmem_path_directory_file_storage_copy (const char *path) { + char *marker = dbmem_path_directory_marker_storage_copy(path); + if (!marker) return NULL; + + size_t len = strlen(marker); + while (len > 0 && dbmem_path_separator(marker[len - 1])) len--; + char *copy = (char *)dbmemory_alloc((uint64_t)len + 1); + if (!copy) { + dbmemory_free(marker); + return NULL; + } + memcpy(copy, marker, len); + copy[len] = '\0'; + dbmemory_free(marker); + return copy; +} + +static bool dbmem_database_path_conflicts (sqlite3 *db, const char *path, const char *source_path) { + static const char *sql = + "SELECT s.source_path FROM dbmem_content c " + "LEFT JOIN dbmem_content_source s ON s.path = c.path " + "WHERE c.path=?1 LIMIT 1;"; + sqlite3_stmt *vm = NULL; + bool conflict = false; + + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) return true; + + sqlite3_bind_text(vm, 1, path, -1, SQLITE_STATIC); + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + const char *stored_source_path = (const char *)sqlite3_column_text(vm, 0); + conflict = source_path && stored_source_path && strcmp(source_path, stored_source_path) != 0; + } else { + conflict = false; + } + + sqlite3_finalize(vm); + return conflict; +} + +static char *dbmem_path_disambiguated_copy (const char *source_path, int components) { + char *suffix = dbmem_path_suffix_copy(source_path, components); + if (suffix) return suffix; + + size_t len = strlen(source_path); + uint64_t hash = dbmem_hash_compute(source_path, len); + const char *base = dbmem_path_basename_ptr(source_path, dbmem_path_trimmed_len(source_path)); + char hash_text[DBMEM_HASH_STR_MAXLEN]; + dbmem_hash_to_hex(hash, hash_text); + + size_t base_len = strlen(base); + size_t total = 9 + 1 + base_len; + char *copy = (char *)dbmemory_alloc((uint64_t)total + 1); + if (!copy) return NULL; + memcpy(copy, hash_text, 8); + copy[8] = '/'; + memcpy(copy + 9, base, base_len); + copy[total] = '\0'; + return copy; +} + +static char *dbmem_path_unique_storage_copy (sqlite3 *db, const char *preferred_path, const char *source_path) { + if (!preferred_path) return NULL; + + if (!source_path || !dbmem_database_path_conflicts(db, preferred_path, source_path)) { + return dbmem_strdup(preferred_path); + } + + int components = dbmem_path_component_count(source_path); + for (int count = 2; count <= components; count++) { + char *candidate = dbmem_path_disambiguated_copy(source_path, count); + if (!candidate) return NULL; + bool conflict = dbmem_database_path_conflicts(db, candidate, source_path); + if (!conflict) return candidate; + dbmemory_free(candidate); + } + + for (int salt = 0; salt < 1000; salt++) { + uint64_t hash = dbmem_hash_compute(source_path, strlen(source_path)); + char hash_text[DBMEM_HASH_STR_MAXLEN]; + dbmem_hash_to_hex(hash + (uint64_t)salt, hash_text); + const char *base = dbmem_path_basename_ptr(source_path, dbmem_path_trimmed_len(source_path)); + size_t base_len = strlen(base); + size_t total = 16 + 1 + base_len; + char *candidate = (char *)dbmemory_alloc((uint64_t)total + 1); + if (!candidate) return NULL; + memcpy(candidate, hash_text, 16); + candidate[16] = '/'; + memcpy(candidate + 17, base, base_len); + candidate[total] = '\0'; + bool conflict = dbmem_database_path_conflicts(db, candidate, source_path); + if (!conflict) return candidate; + dbmemory_free(candidate); + } + + return NULL; +} + static int dbmem_process_file (dbmem_context *ctx, const char *path) { if (!dbmem_file_exists(path)) { dbmem_context_set_errorf(ctx, "Unable to find file at path %s", path); @@ -1497,12 +3280,22 @@ static int dbmem_process_file (dbmem_context *ctx, const char *path) { dbmem_context_set_errorf(ctx, "Unable to read file at path %s", path); return -1; } - + // do real processing - ctx->path = path; + char *stored_path = dbmem_path_storage_copy(path, ctx->root_path); + if (!stored_path) { + dbmemory_free(buffer); + return SQLITE_NOMEM; + } + + ctx->path = stored_path; + ctx->source_path = path; int rc = dbmem_process_buffer(ctx, buffer, len); + ctx->path = NULL; + ctx->source_path = NULL; + dbmemory_free(stored_path); dbmemory_free(buffer); - + DEBUG_DBMEM("%*d\t%s", 4, (int)ctx->counter, path); return rc; } @@ -1518,7 +3311,11 @@ static int dbmem_reindex (dbmem_context *ctx) { // copy all content to a temp table sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_reindex;", NULL, NULL, NULL); - rc = sqlite3_exec(db, "CREATE TEMP TABLE dbmem_reindex AS SELECT path, value, context FROM dbmem_content;", NULL, NULL, NULL); + rc = sqlite3_exec(db, + "CREATE TEMP TABLE dbmem_reindex AS " + "SELECT c.path, s.source_path, c.value, c.context " + "FROM dbmem_content c LEFT JOIN dbmem_content_source s ON s.path = c.path;", + NULL, NULL, NULL); if (rc != SQLITE_OK) return rc; rc = sqlite3_exec(db, "SAVEPOINT dbmem_reindex;", NULL, NULL, NULL); @@ -1532,6 +3329,8 @@ static int dbmem_reindex (dbmem_context *ctx) { } rc = sqlite3_exec(db, "DELETE FROM dbmem_vault;", NULL, NULL, NULL); if (rc != SQLITE_OK) goto cleanup; + rc = sqlite3_exec(db, "DELETE FROM dbmem_content_source;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; rc = sqlite3_exec(db, "DELETE FROM dbmem_content;", NULL, NULL, NULL); if (rc != SQLITE_OK) goto cleanup; @@ -1541,22 +3340,34 @@ static int dbmem_reindex (dbmem_context *ctx) { ctx->vector_extension_available = false; // iterate temp table one row at a time - rc = sqlite3_prepare_v2(db, "SELECT path, value, context FROM dbmem_reindex;", -1, &vm, NULL); + rc = sqlite3_prepare_v2(db, "SELECT path, source_path, value, context FROM dbmem_reindex;", -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { const char *path = (const char *)sqlite3_column_text(vm, 0); - const char *value = (const char *)sqlite3_column_text(vm, 1); - int value_len = sqlite3_column_bytes(vm, 1); - const char *context = (const char *)sqlite3_column_text(vm, 2); + const char *source_path = (const char *)sqlite3_column_text(vm, 1); + const char *value = (const char *)sqlite3_column_text(vm, 2); + int value_len = sqlite3_column_bytes(vm, 2); + const char *context = (const char *)sqlite3_column_text(vm, 3); dbmem_context_reset_temp_values(ctx); ctx->context = context; - if (path && dbmem_file_exists(path)) { + if (source_path && dbmem_file_exists(source_path)) { + rc = dbmem_process_file(ctx, source_path); + } else if (path && dbmem_file_exists(path)) { rc = dbmem_process_file(ctx, path); } else if (value && value_len > 0) { ctx->path = path; + ctx->source_path = source_path; + rc = dbmem_process_buffer(ctx, value, value_len); + } else if (value && value_len == 0 && path && dbmem_path_is_directory_marker(path)) { + ctx->path = path; + ctx->source_path = source_path; + rc = dbmem_database_add_directory_marker(ctx, path, value, value_len); + } else if (value && value_len == 0) { + ctx->path = path; + ctx->source_path = source_path; rc = dbmem_process_buffer(ctx, value, value_len); } else { rc = SQLITE_OK; @@ -1564,107 +3375,440 @@ static int dbmem_reindex (dbmem_context *ctx) { if (rc != SQLITE_OK) goto cleanup; } - if (rc == SQLITE_DONE) rc = SQLITE_OK; + if (rc == SQLITE_DONE) rc = SQLITE_OK; + + if (rc == SQLITE_OK && reindex_started) { + rc = sqlite3_exec(db, "RELEASE dbmem_reindex;", NULL, NULL, NULL); + reindex_started = false; + } + +cleanup: + if (vm) sqlite3_finalize(vm); + if (rc != SQLITE_OK) { + if (reindex_started) { + sqlite3_exec(db, "ROLLBACK TO dbmem_reindex; RELEASE dbmem_reindex;", NULL, NULL, NULL); + } + ctx->dimension = saved_dimension; + ctx->dimension_saved = saved_dimension_saved; + ctx->vector_extension_available = saved_vector_extension_available; + } + dbmem_context_reset_temp_values(ctx); + sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_reindex;", NULL, NULL, NULL); + return rc; +} + +static void dbmem_add_text (sqlite3_context *context, int argc, sqlite3_value **argv) { + // sanity check type + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_text expects a parameter of type TEXT", -1); + return; + } + + // retrieve dbmem_context + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); + sqlite3 *db = sqlite3_context_db_handle(context); + const char *content = (const char *)sqlite3_value_text(argv[0]); + int len = sqlite3_value_bytes(argv[0]); + + // reset temp values + dbmem_context_reset_temp_values(ctx); + + // check for optional memory context + if ((argc == 2) && (sqlite3_value_type(argv[1]) == SQLITE_TEXT)) { + ctx->context = (const char *)sqlite3_value_text(argv[1]); + } + + int rc = dbmem_process_buffer(ctx, content, len); + (rc == 0) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg[0] ? ctx->error_msg : sqlite3_errmsg(db), -1); +} + +static void dbmem_add_content (sqlite3_context *context, int argc, sqlite3_value **argv) { + // sanity check type + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_content expects the first parameter to be of type TEXT", -1); + return; + } + if (sqlite3_value_type(argv[1]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_content expects the second parameter to be of type TEXT", -1); + return; + } + if (argc == 3 && sqlite3_value_type(argv[2]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_content expects the third parameter to be of type TEXT", -1); + return; + } + + // retrieve dbmem_context + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); + const char *path = (const char *)sqlite3_value_text(argv[0]); + const char *content = (const char *)sqlite3_value_text(argv[1]); + int len = sqlite3_value_bytes(argv[1]); + bool is_directory_marker = dbmem_path_has_trailing_separator(path); + + // reset temp values + dbmem_context_reset_temp_values(ctx); + + // check for optional memory context + if (argc == 3) { + ctx->context = (const char *)sqlite3_value_text(argv[2]); + } + + if (is_directory_marker && len != 0) { + sqlite3_result_error(context, "memory_add_content directory markers require empty content", -1); + return; + } + if (is_directory_marker && !ctx->preserve_duplicate_paths) { + sqlite3_result_error(context, "memory_add_content directory markers require preserve_duplicate_paths=1", -1); + return; + } + + char *stored_path = is_directory_marker + ? dbmem_path_directory_marker_storage_copy(path) + : dbmem_path_storage_copy(path, NULL); + if (!stored_path) { + sqlite3_result_error_nomem(context); + return; + } + + sqlite3 *db = sqlite3_context_db_handle(context); + bool conflict = false; + int conflict_rc = SQLITE_OK; + char *conflict_path = is_directory_marker + ? dbmem_path_directory_file_storage_copy(path) + : dbmem_path_directory_marker_storage_copy(path); + if (!conflict_path) { + dbmemory_free(stored_path); + sqlite3_result_error_nomem(context); + return; + } + + conflict_rc = dbmem_database_path_exists(db, conflict_path, &conflict); + dbmemory_free(conflict_path); + if (conflict_rc != SQLITE_OK) { + dbmemory_free(stored_path); + sqlite3_result_error(context, sqlite3_errmsg(db), -1); + return; + } + if (conflict) { + dbmemory_free(stored_path); + sqlite3_result_error(context, "memory_add_content path conflicts with an existing file or directory marker", -1); + return; + } + + ctx->path = stored_path; + int rc = is_directory_marker + ? dbmem_database_add_directory_marker(ctx, stored_path, content, len) + : dbmem_process_buffer(ctx, content, len); + ctx->path = NULL; + dbmemory_free(stored_path); + + (rc == 0) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg[0] ? ctx->error_msg : sqlite3_errmsg(db), -1); +} + +#ifndef DBMEM_OMIT_IO +static int dbmem_scan_callback (const char *path, void *data) { + dbmem_context *ctx = (dbmem_context *)data; + + int rc = dbmem_process_file(ctx, path); + if (rc == 0) ctx->counter++; + + return rc; +} + +static void dbmem_add_file (sqlite3_context *context, int argc, sqlite3_value **argv) { + // sanity check type + if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_file expects the first parameter to be of type TEXT", -1); + return; + } + if (argc == 2 && sqlite3_value_type(argv[1]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_add_file expects the second parameter to be of type TEXT", -1); + return; + } + + // retrieve dbmem_context + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); + const char *path = (const char *)sqlite3_value_text(argv[0]); + + // reset temp values + dbmem_context_reset_temp_values(ctx); + + // check for optional memory context + if (argc == 2) { + ctx->context = (const char *)sqlite3_value_text(argv[1]); + } + + int rc = dbmem_process_file(ctx, path); + (rc == 0) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg, -1); +} + +static int dbmem_make_directory (const char *path) { + if (dbmem_dir_exists(path)) return SQLITE_OK; + + #ifdef _WIN32 + int rc = _mkdir(path); + #else + int rc = mkdir(path, 0755); + #endif + + if (rc == 0 || (errno == EEXIST && dbmem_dir_exists(path))) return SQLITE_OK; + return SQLITE_IOERR; +} + +static size_t dbmem_path_root_len (const char *path) { + if (!path || !path[0]) return 0; + + if (dbmem_path_separator(path[0]) && dbmem_path_separator(path[1])) { + size_t i = 2; + while (path[i] && !dbmem_path_separator(path[i])) i++; + if (path[i]) i++; + while (path[i] && !dbmem_path_separator(path[i])) i++; + if (path[i]) i++; + return i; + } + + if (((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && path[1] == ':') { + return dbmem_path_separator(path[2]) ? 3 : 2; + } - if (rc == SQLITE_OK && reindex_started) { - rc = sqlite3_exec(db, "RELEASE dbmem_reindex;", NULL, NULL, NULL); - reindex_started = false; + return dbmem_path_separator(path[0]) ? 1 : 0; +} + +static int dbmem_ensure_directory (const char *path) { + if (!path || !path[0]) return SQLITE_OK; + + char *copy = dbmem_strdup(path); + if (!copy) return SQLITE_NOMEM; + + size_t len = strlen(copy); + size_t root_len = dbmem_path_root_len(copy); + while (len > root_len && dbmem_path_separator(copy[len - 1])) copy[--len] = '\0'; + if (len <= root_len) { dbmemory_free(copy); return SQLITE_OK; } + + int rc = SQLITE_OK; + for (size_t i = root_len; i < len; i++) { + if (!dbmem_path_separator(copy[i])) continue; + if (i == root_len || dbmem_path_separator(copy[i - 1])) continue; + + char saved = copy[i]; + copy[i] = '\0'; + rc = dbmem_make_directory(copy); + copy[i] = saved; + if (rc != SQLITE_OK) goto cleanup; } + rc = dbmem_make_directory(copy); + cleanup: - if (vm) sqlite3_finalize(vm); - if (rc != SQLITE_OK) { - if (reindex_started) { - sqlite3_exec(db, "ROLLBACK TO dbmem_reindex; RELEASE dbmem_reindex;", NULL, NULL, NULL); + dbmemory_free(copy); + return rc; +} + +static int dbmem_ensure_parent_directory (const char *path) { + if (!path || !path[0]) return SQLITE_ERROR; + + size_t len = strlen(path); + size_t root_len = dbmem_path_root_len(path); + size_t parent_len = 0; + for (size_t i = len; i > root_len; i--) { + if (dbmem_path_separator(path[i - 1])) { + parent_len = i - 1; + break; } - ctx->dimension = saved_dimension; - ctx->dimension_saved = saved_dimension_saved; - ctx->vector_extension_available = saved_vector_extension_available; } - dbmem_context_reset_temp_values(ctx); - sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_reindex;", NULL, NULL, NULL); + if (parent_len <= root_len) return SQLITE_OK; + + char *parent = (char *)dbmemory_alloc((uint64_t)parent_len + 1); + if (!parent) return SQLITE_NOMEM; + memcpy(parent, path, parent_len); + parent[parent_len] = '\0'; + + int rc = dbmem_ensure_directory(parent); + dbmemory_free(parent); return rc; } -static int dbmem_scan_callback (const char *path, void *data) { - dbmem_context *ctx = (dbmem_context *)data; - - int rc = dbmem_process_file(ctx, path); - if (rc == 0) ctx->counter++; - +static int dbmem_write_file_bytes (const char *path, const char *content, int len) { + int64_t existing_len = 0; + char *existing = dbmem_file_read(path, &existing_len); + if (existing) { + bool same = existing_len == len && memcmp(existing, content, (size_t)len) == 0; + dbmemory_free(existing); + if (same) return SQLITE_OK; + } + + int rc = dbmem_ensure_parent_directory(path); + if (rc != SQLITE_OK) return rc; + + FILE *file = fopen(path, "wb"); + if (!file) return SQLITE_IOERR; + + size_t written = fwrite(content, 1, (size_t)len, file); + if (written != (size_t)len) rc = SQLITE_IOERR_WRITE; + if (fclose(file) != 0 && rc == SQLITE_OK) rc = SQLITE_IOERR_CLOSE; + return rc; } -static void dbmem_add_text (sqlite3_context *context, int argc, sqlite3_value **argv) { - // sanity check type - if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_add_text expects a parameter of type TEXT", SQLITE_ERROR); - return; +static char *dbmem_path_join_root (const char *root, const char *path) { + if (!root || !root[0] || dbmem_path_is_absolute(path)) return dbmem_strdup(path); + + size_t root_len = dbmem_path_trimmed_len(root); + size_t path_len = strlen(path); + while (path_len > 0 && dbmem_path_separator(*path)) { + path++; + path_len--; } - - // retrieve dbmem_context - dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); - const char *content = (const char *)sqlite3_value_text(argv[0]); - int len = sqlite3_value_bytes(argv[0]); - - // reset temp values - dbmem_context_reset_temp_values(ctx); - - // check for optional memory context - if ((argc == 2) && (sqlite3_value_type(argv[1]) == SQLITE_TEXT)) { - ctx->context = (const char *)sqlite3_value_text(argv[1]); + + size_t total = root_len + 1 + path_len; + char *joined = (char *)dbmemory_alloc((uint64_t)total + 1); + if (!joined) return NULL; + + memcpy(joined, root, root_len); + #ifdef _WIN32 + joined[root_len] = '\\'; + #else + joined[root_len] = '/'; + #endif + memcpy(joined + root_len + 1, path, path_len); + joined[total] = '\0'; + return joined; +} + +static bool dbmem_path_has_parent_segment (const char *path) { + if (!path) return false; + + size_t i = 0; + while (path[i]) { + while (dbmem_path_separator(path[i])) i++; + size_t start = i; + while (path[i] && !dbmem_path_separator(path[i])) i++; + if ((i - start) == 2 && path[start] == '.' && path[start + 1] == '.') return true; } - - int rc = dbmem_process_buffer(ctx, content, len); - (rc == 0) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg, -1); + + return false; } -#ifndef DBMEM_OMIT_IO -static void dbmem_add_file (sqlite3_context *context, int argc, sqlite3_value **argv) { - // sanity check type - if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_add_file expects the first parameter to be of type TEXT", SQLITE_ERROR); +static char *dbmem_materialize_path_copy (const char *root, const char *path, int *rc) { + *rc = SQLITE_OK; + + if (root && root[0]) { + char *logical_path = dbmem_path_storage_copy(path, NULL); + if (!logical_path) { + *rc = SQLITE_NOMEM; + return NULL; + } + if (dbmem_path_has_parent_segment(logical_path)) { + dbmemory_free(logical_path); + *rc = SQLITE_MISUSE; + return NULL; + } + + char *write_path = dbmem_path_join_root(root, logical_path); + dbmemory_free(logical_path); + if (!write_path) *rc = SQLITE_NOMEM; + return write_path; + } + + if (dbmem_path_has_parent_segment(path)) { + *rc = SQLITE_MISUSE; + return NULL; + } + + return dbmem_path_join_root(root, path); +} + +static void dbmem_materialize_files (sqlite3_context *context, int argc, sqlite3_value **argv) { + if (argc == 1 && sqlite3_value_type(argv[0]) != SQLITE_TEXT) { + sqlite3_result_error(context, "The function memory_materialize_files expects an optional root path of type TEXT", -1); return; } - - // retrieve dbmem_context - dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); - const char *path = (const char *)sqlite3_value_text(argv[0]); - - // reset temp values - dbmem_context_reset_temp_values(ctx); - - // check for optional memory context - if ((argc == 2) && (sqlite3_value_type(argv[1]) == SQLITE_TEXT)) { - ctx->context = (const char *)sqlite3_value_text(argv[1]); + + sqlite3 *db = sqlite3_context_db_handle(context); + sqlite3_stmt *vm = NULL; + const char *root = (argc == 1) ? (const char *)sqlite3_value_text(argv[0]) : NULL; + static const char *sql = + "SELECT path, value FROM dbmem_content WHERE path IS NOT NULL AND path != '' ORDER BY path;"; + + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) goto cleanup; + + sqlite3_int64 count = 0; + while ((rc = sqlite3_step(vm)) == SQLITE_ROW) { + const char *path = (const char *)sqlite3_column_text(vm, 0); + const char *content = (const char *)sqlite3_column_text(vm, 1); + int len = sqlite3_column_bytes(vm, 1); + bool is_directory_marker = dbmem_path_is_directory_marker(path); + + if (!content) { + sqlite3_result_error(context, "memory_materialize_files cannot materialize rows with NULL content", -1); + sqlite3_finalize(vm); + return; + } + + int path_rc = SQLITE_OK; + char *write_path = dbmem_materialize_path_copy(root, path, &path_rc); + if (!write_path) { + if (path_rc == SQLITE_NOMEM) { + sqlite3_result_error_nomem(context); + } else { + sqlite3_result_error(context, "memory_materialize_files refuses paths containing '..' segments", -1); + } + sqlite3_finalize(vm); + return; + } + + rc = is_directory_marker + ? dbmem_ensure_directory(write_path) + : dbmem_write_file_bytes(write_path, content, len); + dbmemory_free(write_path); + if (rc != SQLITE_OK) { + sqlite3_result_error(context, is_directory_marker ? "memory_materialize_files failed to create a directory" : "memory_materialize_files failed to write a file", -1); + sqlite3_finalize(vm); + return; + } + count++; + } + if (rc == SQLITE_DONE) rc = SQLITE_OK; + +cleanup: + if (vm) sqlite3_finalize(vm); + if (rc == SQLITE_OK) { + sqlite3_result_int64(context, count); + } else { + sqlite3_result_error(context, sqlite3_errmsg(db), -1); } - - int rc = dbmem_process_file(ctx, path); - (rc == 0) ? sqlite3_result_int(context, 1) : sqlite3_result_error(context, ctx->error_msg, -1); } static bool dbmem_path_is_under_directory (const char *path, const char *dir_path) { if (!path || !dir_path) return false; + size_t path_len = strlen(path); size_t dir_len = strlen(dir_path); if (dir_len == 0) return false; while (dir_len > 1 && (dir_path[dir_len - 1] == '/' || dir_path[dir_len - 1] == '\\')) { dir_len--; } + if (path_len < dir_len) return false; if (dir_len == 1 && (dir_path[0] == '/' || dir_path[0] == '\\')) { - return path[0] == dir_path[0]; + return dbmem_path_char_equal(path[0], dir_path[0]); } - if (strncmp(path, dir_path, dir_len) != 0) return false; + for (size_t i = 0; i < dir_len; i++) { + if (!dbmem_path_char_equal(path[i], dir_path[i])) return false; + } if (path[dir_len] == '\0') return true; return (path[dir_len] == '/' || path[dir_len] == '\\'); } static void dbmem_database_delete_missing_files (sqlite3 *db, const char *dir_path) { - static const char *sql = "SELECT hash, path FROM dbmem_content WHERE path IS NOT NULL AND path != '';"; + static const char *sql = + "SELECT c.hash, c.path, s.source_path " + "FROM dbmem_content c " + "LEFT JOIN dbmem_content_source s ON s.path = c.path " + "WHERE c.path IS NOT NULL AND c.path != '';"; sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) return; @@ -1679,8 +3823,20 @@ static void dbmem_database_delete_missing_files (sqlite3 *db, const char *dir_pa uint64_t hash = 0; const char *hash_text = (const char *)sqlite3_column_text(vm, 0); const char *path = (const char *)sqlite3_column_text(vm, 1); - if (!dbmem_path_is_under_directory(path, dir_path)) continue; - if (dbmem_file_exists(path)) continue; + const char *source_path = (const char *)sqlite3_column_text(vm, 2); + + if (dbmem_path_is_directory_marker(path)) continue; + + bool exists = false; + if (source_path && source_path[0]) { + if (!dbmem_path_is_under_directory(source_path, dir_path)) continue; + exists = dbmem_file_exists(source_path); + } else { + if (!dbmem_path_is_under_directory(path, dir_path)) continue; + exists = dbmem_file_exists(path); + } + + if (exists) continue; if (!dbmem_hash_from_hex(hash_text, &hash)) continue; dbmem_database_delete_hash(db, hash); } @@ -1697,7 +3853,7 @@ static void dbmem_database_delete_missing_files (sqlite3 *db, const char *dir_pa static void dbmem_add_directory (sqlite3_context *context, int argc, sqlite3_value **argv) { // sanity check type if (sqlite3_value_type(argv[0]) != SQLITE_TEXT) { - sqlite3_result_error(context, "The function memory_add_directory expects the first parameter to be of type TEXT", SQLITE_ERROR); + sqlite3_result_error(context, "The function memory_add_directory expects the first parameter to be of type TEXT", -1); return; } @@ -1707,6 +3863,7 @@ static void dbmem_add_directory (sqlite3_context *context, int argc, sqlite3_val // reset temp values dbmem_context_reset_temp_values(ctx); + ctx->root_path = path; // check for optional memory context if ((argc == 2) && (sqlite3_value_type(argv[1]) == SQLITE_TEXT)) { @@ -1715,7 +3872,7 @@ static void dbmem_add_directory (sqlite3_context *context, int argc, sqlite3_val if (!dbmem_dir_exists(path)) { snprintf(ctx->error_msg, DBMEM_ERRBUF_SIZE, "Unable to find directory at path %s", path); - sqlite3_result_error(context, ctx->error_msg, SQLITE_ERROR); + sqlite3_result_error(context, ctx->error_msg, -1); return; } @@ -1740,21 +3897,24 @@ static void dbmem_sql_reindex (sqlite3_context *context, int argc, sqlite3_value return; } - // Process one row at a time: finalize read cursor before each write to avoid conflicts - static const char *find_sql = - "SELECT path, value, context FROM dbmem_content " - "WHERE value IS NOT NULL AND hash NOT IN (SELECT DISTINCT hash FROM dbmem_vault) " - "LIMIT 1;"; - ctx->reindex_mode = true; dbmem_context_reset_temp_values(ctx); int64_t processed = 0; int rc = SQLITE_OK; + sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_reindex_pending;", NULL, NULL, NULL); + rc = sqlite3_exec(db, + "CREATE TEMP TABLE dbmem_reindex_pending AS " + "SELECT hash, path, value, context FROM dbmem_content WHERE value IS NOT NULL;", + NULL, NULL, NULL); + if (rc != SQLITE_OK) goto done; + while (1) { sqlite3_stmt *vm = NULL; - rc = sqlite3_prepare_v2(db, find_sql, -1, &vm, NULL); + rc = sqlite3_prepare_v2(db, + "SELECT rowid, hash, path, value, context FROM dbmem_reindex_pending LIMIT 1;", + -1, &vm, NULL); if (rc != SQLITE_OK) break; int step = sqlite3_step(vm); @@ -1769,11 +3929,14 @@ static void dbmem_sql_reindex (sqlite3_context *context, int argc, sqlite3_value } // Copy row data before finalizing so we can write in the next step - const char *path_raw = (const char *)sqlite3_column_text(vm, 0); - const char *value_raw = (const char *)sqlite3_column_text(vm, 1); - int64_t value_len = (int64_t)sqlite3_column_bytes(vm, 1); - const char *ctx_raw = (const char *)sqlite3_column_text(vm, 2); - + sqlite3_int64 pending_rowid = sqlite3_column_int64(vm, 0); + const char *hash_raw = (const char *)sqlite3_column_text(vm, 1); + const char *path_raw = (const char *)sqlite3_column_text(vm, 2); + const char *value_raw = (const char *)sqlite3_column_text(vm, 3); + int64_t value_len = (int64_t)sqlite3_column_bytes(vm, 3); + const char *ctx_raw = (const char *)sqlite3_column_text(vm, 4); + + char *hash_text = dbmem_strdup(hash_raw); char *path = dbmem_strdup(path_raw); char *value = (char *)sqlite3_malloc64((sqlite3_uint64)(value_len + 1)); if (value) { memcpy(value, value_raw, (size_t)value_len); value[value_len] = '\0'; } @@ -1781,35 +3944,187 @@ static void dbmem_sql_reindex (sqlite3_context *context, int argc, sqlite3_value sqlite3_finalize(vm); - if (!value) { + if (!hash_text || !path || !value) { + dbmemory_free(hash_text); dbmemory_free(path); + if (value) sqlite3_free(value); dbmemory_free(ctx_name); rc = SQLITE_NOMEM; break; } - ctx->path = path; - ctx->context = ctx_name; - rc = dbmem_process_buffer(ctx, value, value_len); - - // After CRDT sync the stored hash (PK) may differ from the hash computed - // from the current value bytes. If so, update dbmem_content.hash so that: - // (1) this row is excluded from future reindex loop iterations, and - // (2) vector search JOINs on vault.hash = content.hash find the entry. - if (rc == SQLITE_OK && path) { - static const char *fix_sql = - "UPDATE dbmem_content SET hash = ?1 WHERE path = ?2 AND hash != ?1;"; - sqlite3_stmt *fix_vm = NULL; - if (sqlite3_prepare_v2(db, fix_sql, -1, &fix_vm, NULL) == SQLITE_OK) { - dbmem_bind_hash(fix_vm, 1, ctx->hash); - sqlite3_bind_text(fix_vm, 2, path, -1, SQLITE_STATIC); - sqlite3_step(fix_vm); - sqlite3_finalize(fix_vm); + uint64_t stored_hash = 0; + if (!dbmem_hash_from_hex(hash_text, &stored_hash)) { + dbmemory_free(hash_text); + dbmemory_free(path); + sqlite3_free(value); + dbmemory_free(ctx_name); + rc = SQLITE_MISMATCH; + break; + } + + uint64_t content_hash = dbmem_hash_compute(value, (size_t)value_len); + uint64_t scoped_hash = dbmem_storage_hash_compute(value, (size_t)value_len, path, true); + bool hash_matches = (stored_hash == content_hash || stored_hash == scoped_hash); + uint64_t target_hash = hash_matches + ? stored_hash + : dbmem_storage_hash_compute(value, (size_t)value_len, path, ctx->preserve_duplicate_paths || dbmem_path_is_directory_marker(path)); + bool target_has_vault = (value_len == 0) || dbmem_database_hash_has_vault(db, target_hash); + bool needs_hash_update = !hash_matches; + bool needs_reindex = (value_len > 0) && (!hash_matches || !target_has_vault); + + if (needs_reindex) { + ctx->path = path; + ctx->context = ctx_name; + rc = dbmem_process_buffer(ctx, value, value_len); + } + + if (rc == SQLITE_OK && needs_hash_update) { + rc = dbmem_database_update_content_hash(db, path, target_hash); + if (rc == SQLITE_OK) { + rc = dbmem_database_delete_index_hash(db, stored_hash); + } + } + + if (rc == SQLITE_OK) { + sqlite3_stmt *delete_vm = NULL; + rc = sqlite3_prepare_v2(db, "DELETE FROM dbmem_reindex_pending WHERE rowid=?1;", -1, &delete_vm, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_int64(delete_vm, 1, pending_rowid); + rc = sqlite3_step(delete_vm); + if (rc == SQLITE_DONE) rc = SQLITE_OK; } + if (delete_vm) sqlite3_finalize(delete_vm); + } + + ctx->path = NULL; + ctx->context = NULL; + dbmemory_free(hash_text); + dbmemory_free(path); + sqlite3_free(value); + dbmemory_free(ctx_name); + + if (rc != SQLITE_OK) break; + if (needs_reindex || needs_hash_update) processed++; + } + +done: + ctx->reindex_mode = false; + ctx->path = NULL; + ctx->context = NULL; + sqlite3_exec(db, "DROP TABLE IF EXISTS dbmem_reindex_pending;", NULL, NULL, NULL); + + if (rc != SQLITE_OK) { + sqlite3_result_error(context, ctx->error_msg[0] ? ctx->error_msg : sqlite3_errmsg(db), -1); + return; + } + + sqlite3_result_int64(context, processed); +} + +// content is pending when it has a body to index but no vault rows (real chunks or sentinel) yet +#define DBMEM_PENDING_PREDICATE \ + "length > 0 AND value IS NOT NULL AND " \ + "NOT EXISTS (SELECT 1 FROM dbmem_vault v WHERE v.hash = dbmem_content.hash)" + +static void dbmem_embed_pending (sqlite3_context *context, int argc, sqlite3_value **argv) { + dbmem_context *ctx = (dbmem_context *)sqlite3_user_data(context); + sqlite3 *db = ctx->db; + + sqlite3_int64 limit = -1; + if (argc == 1) { + if (sqlite3_value_type(argv[0]) != SQLITE_INTEGER || sqlite3_value_int64(argv[0]) <= 0) { + sqlite3_result_error(context, "The function memory_embed_pending expects a positive INTEGER limit", -1); + return; + } + limit = sqlite3_value_int64(argv[0]); + } + + if (!ctx->model) { + sqlite3_result_error(context, "memory_embed_pending: no embedding model configured", -1); + return; + } + + ctx->reindex_mode = true; + dbmem_context_reset_temp_values(ctx); + + int64_t processed = 0; + int rc = SQLITE_OK; + + while (limit < 0 || processed < limit) { + sqlite3_stmt *vm = NULL; + rc = sqlite3_prepare_v2(db, + "SELECT hash, path, value, context FROM dbmem_content " + "WHERE " DBMEM_PENDING_PREDICATE " LIMIT 1;", + -1, &vm, NULL); + if (rc != SQLITE_OK) break; + + int step = sqlite3_step(vm); + if (step == SQLITE_DONE) { + sqlite3_finalize(vm); + break; + } + if (step != SQLITE_ROW) { + sqlite3_finalize(vm); + rc = step; + break; + } + + // Copy row data before finalizing so we can write in the next step + const char *hash_raw = (const char *)sqlite3_column_text(vm, 0); + const char *path_raw = (const char *)sqlite3_column_text(vm, 1); + const char *value_raw = (const char *)sqlite3_column_text(vm, 2); + int64_t value_len = (int64_t)sqlite3_column_bytes(vm, 2); + const char *ctx_raw = (const char *)sqlite3_column_text(vm, 3); + + char *hash_text = dbmem_strdup(hash_raw); + char *path = dbmem_strdup(path_raw); + char *value = (char *)sqlite3_malloc64((sqlite3_uint64)(value_len + 1)); + if (value) { memcpy(value, value_raw, (size_t)value_len); value[value_len] = '\0'; } + char *ctx_name = dbmem_strdup(ctx_raw); + + sqlite3_finalize(vm); + + if (!hash_text || !path || !value) { + dbmemory_free(hash_text); + dbmemory_free(path); + if (value) sqlite3_free(value); + dbmemory_free(ctx_name); + rc = SQLITE_NOMEM; + break; + } + + uint64_t stored_hash = 0; + if (!dbmem_hash_from_hex(hash_text, &stored_hash)) { + dbmemory_free(hash_text); + dbmemory_free(path); + sqlite3_free(value); + dbmemory_free(ctx_name); + rc = SQLITE_MISMATCH; + break; + } + + // process_buffer recomputes the hash from value/path; when the stored hash was computed + // with a different preserve_duplicate_paths scope, rekey the row to keep the new vault + // rows attached to it (same approach as memory_reindex) + uint64_t target_hash = dbmem_storage_hash_compute(value, (size_t)value_len, path, ctx->preserve_duplicate_paths); + bool target_has_vault = dbmem_database_hash_has_vault(db, target_hash); + + if (!target_has_vault) { + ctx->path = path; + ctx->context = ctx_name; + rc = dbmem_process_buffer(ctx, value, value_len); } + if (rc == SQLITE_OK && target_hash != stored_hash) { + rc = dbmem_database_update_content_hash(db, path, target_hash); + } + + ctx->path = NULL; + ctx->context = NULL; + dbmemory_free(hash_text); dbmemory_free(path); - dbmemory_free(value); + sqlite3_free(value); dbmemory_free(ctx_name); if (rc != SQLITE_OK) break; @@ -1828,6 +4143,25 @@ static void dbmem_sql_reindex (sqlite3_context *context, int argc, sqlite3_value sqlite3_result_int64(context, processed); } +static void dbmem_pending_count (sqlite3_context *context, int argc, sqlite3_value **argv) { + UNUSED_PARAM(argc); UNUSED_PARAM(argv); + static const char *sql = "SELECT COUNT(*) FROM dbmem_content WHERE " DBMEM_PENDING_PREDICATE ";"; + + sqlite3 *db = sqlite3_context_db_handle(context); + sqlite3_stmt *vm = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc == SQLITE_OK) { + rc = sqlite3_step(vm); + if (rc == SQLITE_ROW) { + sqlite3_result_int64(context, sqlite3_column_int64(vm, 0)); + rc = SQLITE_OK; + } + } + + if (vm) sqlite3_finalize(vm); + if (rc != SQLITE_OK) sqlite3_result_error(context, sqlite3_errmsg(db), -1); +} + // MARK: - Sync static void dbmem_enable_sync (sqlite3_context *context, int argc, sqlite3_value **argv) { @@ -1978,19 +4312,28 @@ SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const // so that ctx ownership only transfers to SQLite once all registrations succeed. rc = sqlite3_create_function_v2(db, "_memory_ctx_ptr", 0, SQLITE_UTF8, ctx, dbmem_ctx_ptr, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } - + rc = sqlite3_create_function_v2(db, "memory_set_option", 2, SQLITE_UTF8, ctx, dbmem_set_option, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } rc = sqlite3_create_function_v2(db, "memory_get_option", 1, SQLITE_UTF8, ctx, dbmem_get_option, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_is_enabled", 0, SQLITE_UTF8, ctx, dbmem_is_enabled, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_set_model", 2, SQLITE_UTF8, ctx, dbmem_set_model, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } rc = sqlite3_create_function_v2(db, "memory_set_apikey", 1, SQLITE_UTF8, ctx, dbmem_set_apikey, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_add_content", 2, SQLITE_UTF8, ctx, dbmem_add_content, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_add_content", 3, SQLITE_UTF8, ctx, dbmem_add_content, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + #ifndef DBMEM_OMIT_IO rc = sqlite3_create_function_v2(db, "memory_add_file", 1, SQLITE_UTF8, ctx, dbmem_add_file, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } @@ -2003,6 +4346,12 @@ SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const rc = sqlite3_create_function_v2(db, "memory_add_directory", 2, SQLITE_UTF8, ctx, dbmem_add_directory, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_materialize_files", 0, SQLITE_UTF8, ctx, dbmem_materialize_files, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_materialize_files", 1, SQLITE_UTF8, ctx, dbmem_materialize_files, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } #endif rc = sqlite3_create_function_v2(db, "memory_add_text", 1, SQLITE_UTF8, ctx, dbmem_add_text, NULL, NULL, NULL); @@ -2017,9 +4366,18 @@ SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const rc = sqlite3_create_function_v2(db, "memory_delete_context", 1, SQLITE_UTF8, ctx, dbmem_delete_context, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_delete_file", 1, SQLITE_UTF8, ctx, dbmem_delete_file, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_clear", 0, SQLITE_UTF8, ctx, dbmem_clear, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_rename_file", 2, SQLITE_UTF8, ctx, dbmem_rename_file, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_list_files", 0, SQLITE_UTF8, ctx, dbmem_list_files, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_cache_clear", 0, SQLITE_UTF8, ctx, dbmem_cache_clear, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } @@ -2029,6 +4387,15 @@ SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const rc = sqlite3_create_function_v2(db, "memory_reindex", 0, SQLITE_UTF8, ctx, dbmem_sql_reindex, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_embed_pending", 0, SQLITE_UTF8, ctx, dbmem_embed_pending, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_embed_pending", 1, SQLITE_UTF8, ctx, dbmem_embed_pending, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + + rc = sqlite3_create_function_v2(db, "memory_pending_count", 0, SQLITE_UTF8, ctx, dbmem_pending_count, NULL, NULL, NULL); + if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } + rc = sqlite3_create_function_v2(db, "memory_enable_sync", -1, SQLITE_UTF8, ctx, dbmem_enable_sync, NULL, NULL, NULL); if (rc != SQLITE_OK) { dbmem_context_free(ctx); return rc; } diff --git a/src/sqlite-memory.h b/src/sqlite-memory.h index f4aaefd..9a99c53 100644 --- a/src/sqlite-memory.h +++ b/src/sqlite-memory.h @@ -26,7 +26,7 @@ extern "C" { #endif -#define SQLITE_DBMEMORY_VERSION "1.0.0" +#define SQLITE_DBMEMORY_VERSION "1.3.5" // public API SQLITE_DBMEMORY_API int sqlite3_memory_init (sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi); @@ -39,13 +39,14 @@ typedef struct dbmem_context dbmem_context; typedef struct { int n_tokens; - int n_tokens_truncated; + bool truncated; int n_embd; float *embedding; // Engine-owned buffer, valid until next call or free } dbmem_embedding_result_t; typedef struct { - // Called when memory_set_model(provider, model) matches this provider. + // Called when memory_set_model(provider, model) matches this provider, or + // lazily on first embedding use when provider/model were loaded from settings. // api_key is the value set via memory_set_apikey() (may be NULL). // xdata is the user-supplied generic pointer from the struct. // Return opaque engine pointer, or NULL on error (fill err_msg). @@ -67,6 +68,7 @@ typedef struct { SQLITE_DBMEMORY_API int sqlite3_memory_register_provider (sqlite3 *db, const char *provider_name, const dbmem_provider_t *provider); void *dbmem_context_engine (dbmem_context *ctx, bool *is_local); +int dbmem_context_ensure_engine (dbmem_context *ctx); bool dbmem_context_is_custom (dbmem_context *ctx); bool dbmem_context_load_vector (dbmem_context *ctx); bool dbmem_context_sync_available (dbmem_context *ctx); diff --git a/test/e2e.c b/test/e2e.c index 41d97ed..65985fd 100644 --- a/test/e2e.c +++ b/test/e2e.c @@ -58,12 +58,13 @@ static int tests_failed = 0; #define TEST(name) static void test_##name(void) #define RUN_TEST(name) do { \ + int _failed_before = tests_failed; \ printf(" Running %s... ", #name); \ fflush(stdout); \ test_##name(); \ tests_run++; \ tests_passed++; \ - printf("PASSED\n"); \ + if (tests_failed == _failed_before) printf("PASSED\n"); \ } while(0) #define ASSERT(cond) do { \ @@ -113,13 +114,40 @@ static int capture_int(void *unused, int ncols, char **values, char **names) { // File helper static void create_test_file(const char *path, const char *content) { - FILE *f = fopen(path, "w"); + FILE *f = fopen(path, "wb"); if (f) { fputs(content, f); fclose(f); } } +static int get_vault_metadata(const char *hash, int *chunk_count, int *min_tokens, int *min_truncated, int *max_truncated) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT COUNT(*), COALESCE(MIN(n_tokens), 0), COALESCE(MIN(truncated), 0), COALESCE(MAX(truncated), 0) " + "FROM dbmem_vault WHERE hash = ?1;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return rc; + + rc = sqlite3_bind_text(stmt, 1, hash, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + sqlite3_finalize(stmt); + return rc; + } + + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + if (chunk_count) *chunk_count = sqlite3_column_int(stmt, 0); + if (min_tokens) *min_tokens = sqlite3_column_int(stmt, 1); + if (min_truncated) *min_truncated = sqlite3_column_int(stmt, 2); + if (max_truncated) *max_truncated = sqlite3_column_int(stmt, 3); + rc = SQLITE_OK; + } + + sqlite3_finalize(stmt); + return rc; +} + // ============================================================================ // Phase 1: Setup // ============================================================================ @@ -242,6 +270,24 @@ TEST(verify_embedding) { sqlite3_finalize(stmt); } +// Verify remote embedding metadata is persisted on the stored chunk. +TEST(verify_embedding_metadata) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT n_tokens, truncated FROM dbmem_vault LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + ASSERT(sqlite3_step(stmt) == SQLITE_ROW); + + int n_tokens = sqlite3_column_int(stmt, 0); + int truncated = sqlite3_column_int(stmt, 1); + sqlite3_finalize(stmt); + + ASSERT(n_tokens > 0); + ASSERT(truncated == 0); + printf("(n_tokens=%d, truncated=%d) ", n_tokens, truncated); +} + // memory_add_text with context (triggers remote embedding) TEST(memory_add_text_context) { ASSERT_SQL_OK(db, "SELECT memory_add_text('SQLite is a C-language library that implements a small, fast, self-contained SQL database engine.', 'test-context');"); @@ -283,9 +329,9 @@ TEST(memory_add_file) { snprintf(sql, sizeof(sql), "SELECT memory_add_file('%s');", filepath); ASSERT_SQL_OK(db, sql); - // Verify file path stored in dbmem_content + // Verify local provenance is stored separately from the logical path result_buf[0] = '\0'; - snprintf(sql, sizeof(sql), "SELECT path FROM dbmem_content WHERE path = '%s';", filepath); + snprintf(sql, sizeof(sql), "SELECT source_path FROM dbmem_content_source WHERE source_path = '%s';", filepath); sqlite3_exec(db, sql, capture_string, NULL, NULL); ASSERT(strcmp(result_buf, filepath) == 0); @@ -424,6 +470,753 @@ TEST(memory_search_statement_reuse) { sqlite3_finalize(stmt); } +// ============================================================================ +// Phase 4b: Long-text chunking + multi-section retrieval +// ============================================================================ + +// A long text with 4 clearly distinct sections, each tagged with a unique +// anchor token so we can verify both (a) the chunker covers the whole text +// and (b) section-specific queries retrieve the matching chunk. +#define LONG_TEXT_ANCHOR_COOKING "ZANZIBAR-PASTA" +#define LONG_TEXT_ANCHOR_KERNEL "QUOKKA-SCHEDULER" +#define LONG_TEXT_ANCHOR_VIOLIN "TARANTELLA-BRIDGE" +#define LONG_TEXT_ANCHOR_ASTRO "BETELGEUSE-PARALLAX" + +static const char *LONG_TEXT = + // Section 1 - cooking + "Cooking pasta well begins with abundant salted water at a rolling boil. " + "The " LONG_TEXT_ANCHOR_COOKING " technique calls for finishing the noodles " + "directly in the sauce, ladling in starchy cooking water until the emulsion " + "clings to each strand. Timing matters more than the package suggests: pull " + "the pasta a minute early and let the residual heat do the rest. " + "Salt aggressively. Stir often. Reserve water before draining. Toss vigorously. " + "Salt aggressively. Stir often. Reserve water before draining. Toss vigorously. " + "\n\n" + // Section 2 - kernel scheduling + "Operating system schedulers balance throughput against latency under load. " + "The " LONG_TEXT_ANCHOR_KERNEL " design favors short interactive tasks by " + "boosting their effective priority for a brief window after a wakeup event, " + "then decaying that boost as CPU time accumulates. This avoids starving " + "background batch work while keeping UI threads responsive. " + "Run queues, vruntime, and load balancing across cores all interact here. " + "Run queues, vruntime, and load balancing across cores all interact here. " + "\n\n" + // Section 3 - violin + "A violin's tone depends as much on setup as on the maker. The " + LONG_TEXT_ANCHOR_VIOLIN " is shaped from well-aged maple and positioned to " + "transmit string vibration to the top plate without damping the upper " + "partials. Soundpost placement, tailgut tension, and bow rosin all subtly " + "shift the instrument's voice. " + "Maple, spruce, varnish, and time. Maple, spruce, varnish, and time. " + "\n\n" + // Section 4 - astronomy + "Measuring stellar distances requires careful baseline geometry. The " + LONG_TEXT_ANCHOR_ASTRO " measurement is challenging because the star is a " + "pulsating red supergiant whose photosphere is not well defined. Modern " + "interferometry combined with Gaia astrometry has narrowed the uncertainty " + "but not eliminated it. " + "Parallax, redshift, standard candles, distance ladder. " + "Parallax, redshift, standard candles, distance ladder. "; + +// Structural: long text produces multiple chunks that fully cover the input, +// every chunk has a valid embedding, and chunk offsets are well-formed. +TEST(memory_add_long_text_chunking) { + // Force raw-text chunking so the chunk count is determined by + // max_tokens/overlay_tokens, not by markdown structure. + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 1);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 80);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 16);"); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_text(?1, 'long-text');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, LONG_TEXT, -1, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + ASSERT(sqlite3_step(stmt) == SQLITE_ROW); + sqlite3_finalize(stmt); + + char hash[DBMEM_HASH_STR_MAXLEN] = {0}; + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'long-text' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(hash, sizeof(hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + ASSERT(strlen(hash) == DBMEM_HASH_HEX_LEN); + + char sql[256]; + snprintf(sql, sizeof(sql), + "SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%s';", hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + int chunk_count = result_int; + ASSERT(chunk_count >= 3); + + snprintf(sql, sizeof(sql), + "SELECT seq, offset, length, embedding, n_tokens, truncated FROM dbmem_vault " + "WHERE hash = '%s' ORDER BY seq;", hash); + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + + int prev_seq = -1; + int prev_offset = -1; + int last_offset = 0, last_length = 0; + int seen = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + int seq = sqlite3_column_int(stmt, 0); + int offset = sqlite3_column_int(stmt, 1); + int length = sqlite3_column_int(stmt, 2); + int bytes = sqlite3_column_bytes(stmt, 3); + int n_tokens = sqlite3_column_int(stmt, 4); + int truncated = sqlite3_column_int(stmt, 5); + + ASSERT(seq == prev_seq + 1); + ASSERT(offset >= prev_offset); + ASSERT(length > 0); + ASSERT(bytes == EXPECTED_DIMENSION * (int)sizeof(float)); + ASSERT(n_tokens > 0); + ASSERT(truncated == 0); + + prev_seq = seq; + prev_offset = offset; + last_offset = offset; + last_length = length; + seen++; + } + sqlite3_finalize(stmt); + ASSERT(seen == chunk_count); + + int total = (int)strlen(LONG_TEXT); + // Allow small tail slack for trailing-whitespace trimming by the parser. + ASSERT(last_offset + last_length >= total - 8); + + printf("(%d chunks covering %d bytes) ", chunk_count, total); + + // Restore defaults for downstream tests. + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 400);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 80);"); +} + +// Retrieval: each section is reachable by a query phrase from that section. +// Asserts on anchor-token presence in the top-3 snippets, not absolute +// ranking, so minor embedding drift will not flake the test. +TEST(memory_search_long_text_sections) { + struct { const char *query; const char *anchor; } cases[] = { + { "finishing pasta in the sauce with starchy water", LONG_TEXT_ANCHOR_COOKING }, + { "boosting interactive task priority after wakeup", LONG_TEXT_ANCHOR_KERNEL }, + { "soundpost placement and string vibration", LONG_TEXT_ANCHOR_VIOLIN }, + { "measuring stellar distance with parallax", LONG_TEXT_ANCHOR_ASTRO }, + }; + int n_cases = (int)(sizeof(cases) / sizeof(cases[0])); + + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.0);"); + + int matched = 0; + for (int i = 0; i < n_cases; i++) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT snippet FROM memory_search(?1, 3);", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, cases[i].query, -1, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + + int found = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *snippet = (const char *)sqlite3_column_text(stmt, 0); + if (snippet && strstr(snippet, cases[i].anchor)) { found = 1; break; } + } + sqlite3_finalize(stmt); + + if (!found) { + printf("FAILED\n Query '%s' did not retrieve anchor '%s' in top 3\n", + cases[i].query, cases[i].anchor); + tests_failed++; + tests_passed--; + return; + } + matched++; + } + + // Surface aggregate per-chunk metadata for the underlying long-text + // corpus (one row in dbmem_content, multiple chunks in dbmem_vault). + char long_text_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + sqlite3_stmt *hstmt = NULL; + int hrc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'long-text' LIMIT 1;", + -1, &hstmt, NULL); + int chunk_count = 0, min_tokens = 0, min_truncated = 0, max_truncated = 0; + if (hrc == SQLITE_OK && sqlite3_step(hstmt) == SQLITE_ROW) { + snprintf(long_text_hash, sizeof(long_text_hash), "%s", + (const char *)sqlite3_column_text(hstmt, 0)); + sqlite3_finalize(hstmt); + get_vault_metadata(long_text_hash, &chunk_count, &min_tokens, + &min_truncated, &max_truncated); + } else { + if (hstmt) sqlite3_finalize(hstmt); + } + + printf("(%d/%d sections retrieved; %d chunks min_n_tok=%d any_trunc=%d) ", + matched, n_cases, chunk_count, min_tokens, max_truncated); + + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.7);"); +} + +// ============================================================================ +// Phase 4c: Single-chunk near the provider token ceiling +// ============================================================================ + +// Control test for memory_search_truncation_signature below: same setup +// (single-chunk-everything, pure-vector ranking, leading-mosaics + tail- +// vents text alongside a short vent reference) but the long text is sized +// to land *under* vectors.space's 1024-token batch ceiling. Expectations: +// +// 1) The long chunk embeds successfully (no provider rejection). +// 2) Stored as exactly one chunk in dbmem_vault. +// 3) A tail-topic query retrieves both the short reference and the long +// chunk in the top-10 — confirming the tail was included in the +// embedding when the input fit in one batch. +// +// Sized at ~5200 bytes. Empirical calibration: 7159 / 9346 / 10075 bytes +// all rejected with the same "input (1026 tokens)" template (so "1026" is +// not a real count — just an "exceeded" sentinel). 7159 / 1024 ≈ 7.0 +// chars-per-token actual ratio for this filler, so 5200 bytes ≈ ~740 +// tokens — clear of the 1024 ceiling. +TEST(memory_search_under_token_limit) { + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 1);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 2048);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 1.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.0);"); + + static const char *SHORT_REF = + "Hydrothermal vents on the deep ocean floor sustain chemosynthetic " + "microbial ecosystems independent of sunlight. Tubeworms and " + "thermophilic archaea metabolize sulfur compounds emitted by the " + "vent fluids in total darkness."; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'under-limit-short');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, SHORT_REF, -1, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + ASSERT(sqlite3_step(stmt) == SQLITE_ROW); + sqlite3_finalize(stmt); + + static const char *MOSAIC_LEAD = + "Andalusian zellige mosaics from medieval Granada and Cordoba feature " + "interlocking geometric tiles arranged in repeating decagonal motifs " + "of cobalt and ochre glaze. "; + static const char *MOSAIC_FILLER = + "Master craftsmen historically cut tesserae from glazed terracotta " + "and fit them into intricate patterns whose mathematical foundations " + "anticipate aperiodic tilings by centuries; pigments include lapis " + "lazuli, copper carbonate, and iron oxides. "; + static const char *VENT_TAIL = + " And entirely separately, deep ocean hydrothermal vents host " + "chemosynthetic communities of microbial mats, tubeworms, and " + "thermophilic archaea metabolizing sulfur compounds in total darkness."; + + size_t cap = 16 * 1024; + char *long_text = (char *)malloc(cap); + ASSERT(long_text != NULL); + size_t pos = 0; + int n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_LEAD); + pos += (size_t)n; + while (pos < 5000 + && pos + strlen(MOSAIC_FILLER) + strlen(VENT_TAIL) + 4 < cap) { + n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_FILLER); + if (n <= 0) break; + pos += (size_t)n; + } + n = snprintf(long_text + pos, cap - pos, "%s", VENT_TAIL); + pos += (size_t)n; + int long_text_len = (int)pos; + + rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'under-limit-long');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, long_text, long_text_len, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + printf("FAILED\n memory_add_text(%d bytes) returned rc=%d\n sqlite error: %s\n", + long_text_len, rc, sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + free(long_text); + tests_failed++; + tests_passed--; + return; + } + sqlite3_finalize(stmt); + free(long_text); + + char short_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + char long_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'under-limit-short' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(short_hash, sizeof(short_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'under-limit-long' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(long_hash, sizeof(long_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + // Single chunk, length around ~5KB but under the rejection threshold. + char sql[256]; + snprintf(sql, sizeof(sql), + "SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%s';", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + ASSERT(result_int == 1); + + snprintf(sql, sizeof(sql), + "SELECT length FROM dbmem_vault WHERE hash = '%s' LIMIT 1;", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + int long_chunk_bytes = result_int; + ASSERT(long_chunk_bytes > 4500); + + int chunk_count = 0, min_tokens = 0, min_truncated = 0, max_truncated = 0; + int short_n_tokens = 0, short_truncated = 0; + int long_n_tokens = 0, long_truncated = 0; + + rc = get_vault_metadata(short_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 0 && max_truncated == 0); + short_n_tokens = min_tokens; + short_truncated = max_truncated; + + rc = get_vault_metadata(long_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 0 && max_truncated == 0); + long_n_tokens = min_tokens; + long_truncated = max_truncated; + + // Same query as the truncation test; with the full chunk embedded we + // expect both the short ref and the long chunk to surface in top-10. + rc = sqlite3_prepare_v2(db, + "SELECT hash, ranking FROM memory_search(" + " 'chemosynthesis around deep-sea volcanic vents', 10);", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + + int short_rank = -1, long_rank = -1; + double short_score = 0.0, long_score = 0.0; + int row = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *hash = (const char *)sqlite3_column_text(stmt, 0); + double rank = sqlite3_column_double(stmt, 1); + if (hash && strcmp(hash, short_hash) == 0) { + short_rank = row; short_score = rank; + } + if (hash && strcmp(hash, long_hash) == 0) { + long_rank = row; long_score = rank; + } + row++; + } + sqlite3_finalize(stmt); + + ASSERT(short_rank >= 0); + ASSERT(long_rank >= 0); + + printf("(short: n_tok=%d trunc=%d rank=%d score=%.3f; long: %d bytes n_tok=%d trunc=%d rank=%d score=%.3f) ", + short_n_tokens, short_truncated, short_rank, short_score, + long_chunk_bytes, long_n_tokens, long_truncated, long_rank, long_score); + + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 400);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 80);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 0.6);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.4);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.7);"); +} + +// ============================================================================ +// Phase 4d: Model-level truncation behavioral signature +// ============================================================================ + +// When a single chunk exceeds the embedding model's input context window +// (embeddinggemma-300m: ~2048 tokens), the service truncates and returns an +// embedding that only represents the leading portion. The truncated flag is +// persisted on dbmem_vault, and this test also checks the observable search +// behavior: +// +// 1) Store a SHORT reference (fully embedded) entirely about topic T. +// 2) Store a LONG single-chunk document whose LEADING ~10KB is about an +// unrelated topic and whose final ~250 bytes (well past the 2048-token +// window) introduce topic T. +// 3) Search for topic T with pure-vector ranking. +// +// If the long chunk's embedding includes the tail, both should rank in the +// same neighborhood. If truncated, the long chunk's embedding only encodes +// the unrelated leading topic and ranks far below the short reference (or +// drops out of the top-K entirely). +TEST(memory_search_truncation_signature) { + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 1);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 3000);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 1.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.0);"); + + // Short reference (~50 tokens), fully embedded, entirely about the topic. + // Trailing sentence differs per test so memory_add_text's content-hash + // idempotency doesn't collapse this insert into a no-op of an earlier + // test's identical SHORT_REF. + static const char *SHORT_REF = + "Hydrothermal vents on the deep ocean floor sustain chemosynthetic " + "microbial ecosystems independent of sunlight. Tubeworms and " + "thermophilic archaea metabolize sulfur compounds emitted by the " + "vent fluids in total darkness. Truncation-signature reference."; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'trunc-short');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, SHORT_REF, -1, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + ASSERT(sqlite3_step(stmt) == SQLITE_ROW); + sqlite3_finalize(stmt); + + // Build ~10KB single-chunk text: leading + filler about Andalusian + // mosaics, then a final ~250-byte tail introducing hydrothermal vents. + // ~10KB / ~4 chars-per-token ≈ 2500 tokens — past gemma's 2048 window. + static const char *MOSAIC_LEAD = + "Andalusian zellige mosaics from medieval Granada and Cordoba feature " + "interlocking geometric tiles arranged in repeating decagonal motifs " + "of cobalt and ochre glaze. "; + static const char *MOSAIC_FILLER = + "Master craftsmen historically cut tesserae from glazed terracotta " + "and fit them into intricate patterns whose mathematical foundations " + "anticipate aperiodic tilings by centuries; pigments include lapis " + "lazuli, copper carbonate, and iron oxides. "; + static const char *VENT_TAIL = + " And entirely separately, deep ocean hydrothermal vents host " + "chemosynthetic communities of microbial mats, tubeworms, and " + "thermophilic archaea metabolizing sulfur compounds in total darkness."; + + size_t cap = 16 * 1024; + char *long_text = (char *)malloc(cap); + ASSERT(long_text != NULL); + size_t pos = 0; + int n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_LEAD); + pos += (size_t)n; + while (pos < 9800 + && pos + strlen(MOSAIC_FILLER) + strlen(VENT_TAIL) + 4 < cap) { + n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_FILLER); + if (n <= 0) break; + pos += (size_t)n; + } + n = snprintf(long_text + pos, cap - pos, "%s", VENT_TAIL); + pos += (size_t)n; + int long_text_len = (int)pos; + + rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'trunc-long');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, long_text, long_text_len, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + printf("FAILED\n memory_add_text(%d bytes) returned rc=%d\n sqlite error: %s\n", + long_text_len, rc, sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + free(long_text); + tests_failed++; + tests_passed--; + return; + } + sqlite3_finalize(stmt); + free(long_text); + + // Capture both hashes. + char short_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + char long_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'trunc-short' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(short_hash, sizeof(short_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'trunc-long' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(long_hash, sizeof(long_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + // Confirm the long content stored as one chunk past gemma's window. + char sql[256]; + snprintf(sql, sizeof(sql), + "SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%s';", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + ASSERT(result_int == 1); + + snprintf(sql, sizeof(sql), + "SELECT length FROM dbmem_vault WHERE hash = '%s' LIMIT 1;", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + int long_chunk_bytes = result_int; + // ~2048 tokens × ~4 chars/token = ~8192 chars; chunk must clearly exceed. + ASSERT(long_chunk_bytes > 9000); + + int chunk_count = 0, min_tokens = 0, min_truncated = 0, max_truncated = 0; + int short_n_tokens = 0, short_truncated = 0; + int long_n_tokens = 0, long_truncated = 0; + + rc = get_vault_metadata(short_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 0 && max_truncated == 0); + short_n_tokens = min_tokens; + short_truncated = max_truncated; + + rc = get_vault_metadata(long_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 1 && max_truncated == 1); + long_n_tokens = min_tokens; + long_truncated = max_truncated; + + // Query for the topic that appears throughout the short reference and + // only in the *tail* of the long chunk. Paraphrased so any residual FTS + // contribution would match both texts roughly equally. + rc = sqlite3_prepare_v2(db, + "SELECT hash, ranking FROM memory_search(" + " 'chemosynthesis around deep-sea volcanic vents', 10);", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + + int short_rank = -1, long_rank = -1; + double short_score = 0.0, long_score = 0.0; + int row = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *hash = (const char *)sqlite3_column_text(stmt, 0); + double rank = sqlite3_column_double(stmt, 1); + if (hash && strcmp(hash, short_hash) == 0) { + short_rank = row; short_score = rank; + } + if (hash && strcmp(hash, long_hash) == 0) { + long_rank = row; long_score = rank; + } + row++; + } + sqlite3_finalize(stmt); + + ASSERT(short_rank >= 0); + if (long_rank == -1) { + printf("(short: n_tok=%d trunc=%d rank=%d score=%.3f; long: %d bytes n_tok=%d trunc=%d absent from top-10) ", + short_n_tokens, short_truncated, short_rank, short_score, + long_chunk_bytes, long_n_tokens, long_truncated); + } else { + // With a fully-embedded long chunk we'd expect comparable rankings; + // truncation pushes the long chunk strictly below the short ref. + ASSERT(short_rank < long_rank); + printf("(short: n_tok=%d trunc=%d rank=%d score=%.3f; long: %d bytes n_tok=%d trunc=%d rank=%d score=%.3f) ", + short_n_tokens, short_truncated, short_rank, short_score, + long_chunk_bytes, long_n_tokens, long_truncated, long_rank, long_score); + } + + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 400);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 80);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 0.6);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.4);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.7);"); +} + +// ============================================================================ +// Phase 4e: Truncation signature near the model context window (~2000 tok) +// ============================================================================ + +// Same shape as memory_search_truncation_signature, but with a long text +// sized at ~19500 bytes / ~9.8 chars-per-token ≈ ~1990 tokens — close to +// embeddinggemma-300m's documented 2048-token context window. Useful for +// observing how the provider behaves further past the 1024-token batch +// ceiling: same rejection error, a different message, or (if the batch +// size is raised on the server) a successful embed where truncation +// actually occurs. +TEST(memory_search_truncation_near_model_context) { + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 1);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 6000);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 1.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.0);"); + + // Trailing sentence differs from the other tests' SHORT_REFs so the + // content-hash idempotency in memory_add_text doesn't collapse the insert. + static const char *SHORT_REF = + "Hydrothermal vents on the deep ocean floor sustain chemosynthetic " + "microbial ecosystems independent of sunlight. Tubeworms and " + "thermophilic archaea metabolize sulfur compounds emitted by the " + "vent fluids in total darkness. Near-context reference."; + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'trunc-large-short');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, SHORT_REF, -1, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + ASSERT(sqlite3_step(stmt) == SQLITE_ROW); + sqlite3_finalize(stmt); + + static const char *MOSAIC_LEAD = + "Andalusian zellige mosaics from medieval Granada and Cordoba feature " + "interlocking geometric tiles arranged in repeating decagonal motifs " + "of cobalt and ochre glaze. "; + static const char *MOSAIC_FILLER = + "Master craftsmen historically cut tesserae from glazed terracotta " + "and fit them into intricate patterns whose mathematical foundations " + "anticipate aperiodic tilings by centuries; pigments include lapis " + "lazuli, copper carbonate, and iron oxides. "; + static const char *VENT_TAIL = + " And entirely separately, deep ocean hydrothermal vents host " + "chemosynthetic communities of microbial mats, tubeworms, and " + "thermophilic archaea metabolizing sulfur compounds in total darkness."; + + size_t cap = 32 * 1024; + char *long_text = (char *)malloc(cap); + ASSERT(long_text != NULL); + size_t pos = 0; + int n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_LEAD); + pos += (size_t)n; + while (pos < 19300 + && pos + strlen(MOSAIC_FILLER) + strlen(VENT_TAIL) + 4 < cap) { + n = snprintf(long_text + pos, cap - pos, "%s", MOSAIC_FILLER); + if (n <= 0) break; + pos += (size_t)n; + } + n = snprintf(long_text + pos, cap - pos, "%s", VENT_TAIL); + pos += (size_t)n; + int long_text_len = (int)pos; + + rc = sqlite3_prepare_v2(db, + "SELECT memory_add_text(?1, 'trunc-large-long');", -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_bind_text(stmt, 1, long_text, long_text_len, SQLITE_STATIC); + ASSERT(rc == SQLITE_OK); + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { + printf("FAILED\n memory_add_text(%d bytes) returned rc=%d\n sqlite error: %s\n", + long_text_len, rc, sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + free(long_text); + tests_failed++; + tests_passed--; + return; + } + sqlite3_finalize(stmt); + free(long_text); + + char short_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + char long_hash[DBMEM_HASH_STR_MAXLEN] = {0}; + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'trunc-large-short' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(short_hash, sizeof(short_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, + "SELECT hash FROM dbmem_content WHERE context = 'trunc-large-long' LIMIT 1;", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW); + snprintf(long_hash, sizeof(long_hash), "%s", (const char *)sqlite3_column_text(stmt, 0)); + sqlite3_finalize(stmt); + + char sql[256]; + snprintf(sql, sizeof(sql), + "SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%s';", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + ASSERT(result_int == 1); + + snprintf(sql, sizeof(sql), + "SELECT length FROM dbmem_vault WHERE hash = '%s' LIMIT 1;", long_hash); + result_int = 0; + sqlite3_exec(db, sql, capture_int, NULL, NULL); + int long_chunk_bytes = result_int; + ASSERT(long_chunk_bytes > 18000); + + int chunk_count = 0, min_tokens = 0, min_truncated = 0, max_truncated = 0; + int short_n_tokens = 0, short_truncated = 0; + int long_n_tokens = 0, long_truncated = 0; + + rc = get_vault_metadata(short_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 0 && max_truncated == 0); + short_n_tokens = min_tokens; + short_truncated = max_truncated; + + rc = get_vault_metadata(long_hash, &chunk_count, &min_tokens, &min_truncated, &max_truncated); + ASSERT(rc == SQLITE_OK); + ASSERT(chunk_count == 1); + ASSERT(min_tokens > 0); + ASSERT(min_truncated == 1 && max_truncated == 1); + long_n_tokens = min_tokens; + long_truncated = max_truncated; + + rc = sqlite3_prepare_v2(db, + "SELECT hash, ranking FROM memory_search(" + " 'chemosynthesis around deep-sea volcanic vents', 10);", + -1, &stmt, NULL); + ASSERT(rc == SQLITE_OK); + + int short_rank = -1, long_rank = -1; + double short_score = 0.0, long_score = 0.0; + int row = 0; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *hash = (const char *)sqlite3_column_text(stmt, 0); + double rank = sqlite3_column_double(stmt, 1); + if (hash && strcmp(hash, short_hash) == 0) { + short_rank = row; short_score = rank; + } + if (hash && strcmp(hash, long_hash) == 0) { + long_rank = row; long_score = rank; + } + row++; + } + sqlite3_finalize(stmt); + + ASSERT(short_rank >= 0); + if (long_rank == -1) { + printf("(short: n_tok=%d trunc=%d rank=%d score=%.3f; long: %d bytes n_tok=%d trunc=%d absent from top-10) ", + short_n_tokens, short_truncated, short_rank, short_score, + long_chunk_bytes, long_n_tokens, long_truncated); + } else { + ASSERT(short_rank < long_rank); + printf("(short: n_tok=%d trunc=%d rank=%d score=%.3f; long: %d bytes n_tok=%d trunc=%d rank=%d score=%.3f) ", + short_n_tokens, short_truncated, short_rank, short_score, + long_chunk_bytes, long_n_tokens, long_truncated, long_rank, long_score); + } + + ASSERT_SQL_OK(db, "SELECT memory_set_option('skip_semantic', 0);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('max_tokens', 400);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('overlay_tokens', 80);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('vector_weight', 0.6);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('text_weight', 0.4);"); + ASSERT_SQL_OK(db, "SELECT memory_set_option('min_score', 0.7);"); +} + // ============================================================================ // Phase 5: Deletion // ============================================================================ @@ -531,6 +1324,7 @@ int main(void) { // Phase 3: Content Management (network calls) RUN_TEST(memory_add_text); RUN_TEST(verify_embedding); + RUN_TEST(verify_embedding_metadata); RUN_TEST(memory_add_text_context); RUN_TEST(memory_add_text_idempotent); #ifndef DBMEM_OMIT_IO @@ -543,6 +1337,19 @@ int main(void) { RUN_TEST(memory_search_ranking); RUN_TEST(memory_search_statement_reuse); + // Phase 4b: Long-text chunking + multi-section retrieval + RUN_TEST(memory_add_long_text_chunking); + RUN_TEST(memory_search_long_text_sections); + + // Phase 4c: Single-chunk near (under) the provider token ceiling + RUN_TEST(memory_search_under_token_limit); + + // Phase 4d: Model-level truncation behavioral signature + RUN_TEST(memory_search_truncation_signature); + + // Phase 4e: Same shape, but text size pushed near the model context window + RUN_TEST(memory_search_truncation_near_model_context); + // Phase 5: Deletion RUN_TEST(memory_delete); RUN_TEST(memory_delete_context); diff --git a/test/sync/README.md b/test/sync/README.md index 009ffcd..9fb0e29 100644 --- a/test/sync/README.md +++ b/test/sync/README.md @@ -129,4 +129,4 @@ The test uses `cloudsync_network_sync(500, 3)` called twice per agent in sequenc Sync is enabled on `dbmem_content`, and the `value` column (which stores the raw text) is configured with the `block` algorithm so that line-level changes from concurrent agents are preserved rather than replaced wholesale. -After receiving content via sync, each agent calls `memory_reindex()` to generate embeddings for the newly arrived rows. Only rows not yet in the local embedding vault are processed, so existing embeddings are never duplicated. +After receiving content via sync, each agent calls `memory_reindex()` to generate embeddings for newly arrived rows and refresh rows whose `value` was merged by sync. Rows whose hash and vault entries are already aligned are skipped, so existing embeddings are not duplicated. diff --git a/test/sync/test_sync.c b/test/sync/test_sync.c index df8013c..25caa70 100644 --- a/test/sync/test_sync.c +++ b/test/sync/test_sync.c @@ -22,12 +22,25 @@ #include #include #include -#include #include "sqlite-memory.h" +#ifdef _WIN32 +#include +#define unlink_p(path) _unlink(path) +#ifndef TEST_TMP_DIR +#define TEST_TMP_DIR "build" +#endif +#else +#include +#define unlink_p(path) unlink(path) +#ifndef TEST_TMP_DIR +#define TEST_TMP_DIR "/tmp" +#endif +#endif + // Temporary database files (cleaned up at start and end) -#define AGENT_A_DB "/tmp/agent_a_memory_test.db" -#define AGENT_B_DB "/tmp/agent_b_memory_test.db" +#define AGENT_A_DB TEST_TMP_DIR "/agent_a_memory_test.db" +#define AGENT_B_DB TEST_TMP_DIR "/agent_b_memory_test.db" // ============================================================================ // Agent A content: James Webb Space Telescope (context: "space") @@ -530,8 +543,8 @@ int main(void) { } // Clean up stale databases from previous runs - unlink(AGENT_A_DB); - unlink(AGENT_B_DB); + unlink_p(AGENT_A_DB); + unlink_p(AGENT_B_DB); printf("\nSync integration test: JWST (Agent A) + Great Barrier Reef (Agent B)\n"); printf("=======================================================================\n\n"); @@ -566,8 +579,8 @@ int main(void) { // Cleanup if (db_a) sqlite3_close(db_a); if (db_b) sqlite3_close(db_b); - unlink(AGENT_A_DB); - unlink(AGENT_B_DB); + unlink_p(AGENT_A_DB); + unlink_p(AGENT_B_DB); printf("\n=== Sync Test Results ===\n"); printf("Tests run: %d\n", tests_run); diff --git a/test/unittest.c b/test/unittest.c index cb0e041..e1b3cfb 100644 --- a/test/unittest.c +++ b/test/unittest.c @@ -31,6 +31,10 @@ #ifdef TEST_SQLITE_EXTENSION #include "sqlite-memory.h" +#ifndef DBMEM_OMIT_LOCAL_ENGINE +#include "ggml.h" +void dbmem_logger(enum ggml_log_level level, const char *text, void *user_data); +#endif #endif // ============================================================================ @@ -136,6 +140,13 @@ static void free_test_ctx(test_ctx_t *ctx) { memset(ctx, 0, sizeof(*ctx)); } +static int test_ctx_contains(test_ctx_t *ctx, const char *needle) { + for (size_t i = 0; i < ctx->count; i++) { + if (strstr(ctx->chunks[i], needle) != NULL) return 1; + } + return 0; +} + // ============================================================================ // dbmem_parse Tests // ============================================================================ @@ -287,6 +298,149 @@ TEST(dbmem_parse_preserves_html) { free_test_ctx(&ctx); } +TEST(dbmem_parse_mdx_strips_esm_and_expressions) { + const char *input = + "import Widget from './Widget';\n" + "export const metadata = {\n" + " title: 'Hidden title',\n" + " description: 'Hidden description'\n" + "};\n" + "\n" + "# Hello {user.name}\n" + "\n" + "Visible inside text.\n" + "{items.map((item) => (\n" + " {item.label}\n" + "))}\n" + "After expression.\n"; + dbmem_parse_settings settings = default_settings(); + settings.mdx_mode = true; + settings.overlay_tokens = 0; + test_ctx_t ctx = {0}; + settings.callback = test_callback; + settings.xdata = &ctx; + + int rc = dbmem_parse(input, strlen(input), &settings); + ASSERT_EQ(rc, 0); + ASSERT(ctx.count >= 1); + ASSERT(test_ctx_contains(&ctx, "Hello")); + ASSERT(test_ctx_contains(&ctx, "Visible inside text.")); + ASSERT(test_ctx_contains(&ctx, "After expression.")); + ASSERT(!test_ctx_contains(&ctx, "Widget from")); + ASSERT(!test_ctx_contains(&ctx, "Hidden title")); + ASSERT(!test_ctx_contains(&ctx, "user.name")); + ASSERT(!test_ctx_contains(&ctx, "items.map")); + ASSERT(!test_ctx_contains(&ctx, "item.label")); + + free_test_ctx(&ctx); +} + +TEST(dbmem_parse_mdx_preserves_fenced_code) { + const char *input = + "Before\n" + "```js\n" + "import Widget from './Widget';\n" + "const node = ;\n" + "```\n" + "After {ignoredExpression}\n"; + dbmem_parse_settings settings = default_settings(); + settings.mdx_mode = true; + test_ctx_t ctx = {0}; + settings.callback = test_callback; + settings.xdata = &ctx; + + int rc = dbmem_parse(input, strlen(input), &settings); + ASSERT_EQ(rc, 0); + ASSERT_EQ(ctx.count, 1); + ASSERT(strstr(ctx.chunks[0], "Before") != NULL); + ASSERT(strstr(ctx.chunks[0], "import Widget from './Widget';") != NULL); + ASSERT(strstr(ctx.chunks[0], "const node = ;") != NULL); + ASSERT(strstr(ctx.chunks[0], "After") != NULL); + ASSERT(strstr(ctx.chunks[0], "ignoredExpression") == NULL); + + free_test_ctx(&ctx); +} + +TEST(dbmem_parse_mdx_keeps_import_export_prose_and_indented_code) { + const char *input = + "# Visible\n" + "import a database from the dashboard.\n" + "export data from SQLite Cloud when needed.\n" + "\n" + " import sqlitecloud\n" + " export default App\n" + "\n" + "import Real from './Real';\n" + "export const hidden = 'not searchable';\n"; + dbmem_parse_settings settings = default_settings(); + settings.mdx_mode = true; + settings.overlay_tokens = 0; + test_ctx_t ctx = {0}; + settings.callback = test_callback; + settings.xdata = &ctx; + + int rc = dbmem_parse(input, strlen(input), &settings); + ASSERT_EQ(rc, 0); + ASSERT(ctx.count >= 1); + ASSERT(test_ctx_contains(&ctx, "import a database from the dashboard.")); + ASSERT(test_ctx_contains(&ctx, "export data from SQLite Cloud when needed.")); + ASSERT(test_ctx_contains(&ctx, "import sqlitecloud")); + ASSERT(test_ctx_contains(&ctx, "export default App")); + ASSERT(!test_ctx_contains(&ctx, "Real from")); + ASSERT(!test_ctx_contains(&ctx, "not searchable")); + + free_test_ctx(&ctx); +} + +TEST(dbmem_parse_mdx_real_docs_file) { + const char *input = + "---\n" + "title: Multi Code Component Examples\n" + "description: Multi Code Component Examples\n" + "slug: multicode\n" + "---\n" + "import MultiCode from '@commons-components/Code/MultiCode.astro';\n" + "\n" + "In this examples, we will show how to use the `MultiCode` component:\n" + "\n" + "---\n" + "## First example\n" + "\n" + "export const WebliteSourceCode = ``;\n" + "\n" + "export const codeExamplesOne = [\n" + " {\n" + " sliderItem: \"Web\",\n" + " codeLines: WebliteSourceCode,\n" + " lang: \"html\",\n" + " }\n" + "];\n" + "\n" + "\n"; + + dbmem_parse_settings settings = default_settings(); + settings.mdx_mode = true; + settings.overlay_tokens = 0; + test_ctx_t ctx = {0}; + settings.callback = test_callback; + settings.xdata = &ctx; + + int rc = dbmem_parse(input, strlen(input), &settings); + ASSERT_EQ(rc, 0); + ASSERT(ctx.count >= 1); + ASSERT(test_ctx_contains(&ctx, "Multi Code Component Examples")); + ASSERT(test_ctx_contains(&ctx, "First example")); + ASSERT(!test_ctx_contains(&ctx, "commons-components")); + ASSERT(!test_ctx_contains(&ctx, "WebliteSourceCode")); + ASSERT(!test_ctx_contains(&ctx, "codeExamplesOne")); + + free_test_ctx(&ctx); +} + TEST(dbmem_parse_skip_semantic) { const char *input = "# Section 1\nContent 1\n# Section 2\nContent 2"; dbmem_parse_settings settings = default_settings(); @@ -1014,7 +1168,7 @@ TEST(dbmem_parse_code_with_markdown_inside) { // Helper to create a file with content static int create_test_file(const char *path, const char *content) { - FILE *f = fopen(path, "w"); + FILE *f = fopen(path, "wb"); if (!f) return -1; if (content) fputs(content, f); fclose(f); @@ -1336,6 +1490,19 @@ static sqlite3 *open_test_db(void) { return db; } +static sqlite3 *open_test_db_path(const char *path) { + sqlite3 *db = NULL; + int rc = sqlite3_open(path, &db); + if (rc != SQLITE_OK) return NULL; + + rc = sqlite3_memory_init(db, NULL, NULL); + if (rc != SQLITE_OK) { + sqlite3_close(db); + return NULL; + } + return db; +} + // Helper to execute SQL and get integer result static int exec_get_int(sqlite3 *db, const char *sql, sqlite3_int64 *result) { sqlite3_stmt *stmt = NULL; @@ -1398,6 +1565,48 @@ TEST(sqlite_memory_version) { sqlite3_close(db); } +TEST(sqlite_memory_is_enabled) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_is_enabled();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_memory_is_enabled_missing_table) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, "DROP TABLE dbmem_cache;", NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 1; + rc = exec_get_int(db, "SELECT memory_is_enabled();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_is_enabled_ignores_schema_version) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, "DELETE FROM dbmem_settings WHERE key='schema_version';", NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 1; + rc = exec_get_int(db, "SELECT memory_is_enabled();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + TEST(sqlite_memory_clear_empty) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -1434,982 +1643,3262 @@ TEST(sqlite_memory_delete_context_nonexistent) { sqlite3_close(db); } -TEST(sqlite_schema_has_timestamps) { +TEST(sqlite_memory_delete_file_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Check that schema includes created_at column - char sql[256]; - int rc = exec_get_text(db, - "SELECT sql FROM sqlite_master WHERE name='dbmem_content';", - sql, sizeof(sql)); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 800), 'docs/delete.md', 'delete me', 9, 'ctx', 0), " + "(printf('%016x', 801), 'docs/keep.md', 'keep me', 7, 'ctx', 0);" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " + "(printf('%016x', 800), 0, X'00000000', 0, 5), " + "(printf('%016x', 800), 1, X'00000000', 5, 4), " + "(printf('%016x', 801), 0, X'00000000', 0, 7);" + "INSERT INTO dbmem_vault_fts (content, hash, seq, context) VALUES " + "('delete chunk 1', printf('%016x', 800), 0, 'ctx'), " + "('delete chunk 2', printf('%016x', 800), 1, 'ctx'), " + "('keep chunk', printf('%016x', 801), 0, 'ctx');", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(strstr(sql, "hash TEXT PRIMARY KEY NOT NULL") != NULL); - ASSERT(strstr(sql, "created_at") != NULL); - ASSERT(strstr(sql, "last_accessed") != NULL); - rc = exec_get_text(db, - "SELECT sql FROM sqlite_master WHERE name='dbmem_vault';", - sql, sizeof(sql)); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_delete_file('docs/delete.md');", &result); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(strstr(sql, "hash TEXT NOT NULL") != NULL); + ASSERT_EQ(result, 1); - rc = exec_get_text(db, - "SELECT sql FROM sqlite_master WHERE name='dbmem_cache';", - sql, sizeof(sql)); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash = printf('%016x', 800);", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(strstr(sql, "text_hash TEXT NOT NULL") != NULL); + ASSERT_EQ(count, 0); - sqlite3_close(db); -} + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 800);", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); -// Test that inserting directly into tables works with new schema -TEST(sqlite_direct_insert_with_timestamp) { - sqlite3 *db = open_test_db(); - ASSERT(db != NULL); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = printf('%016x', 800);", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); - // Insert a test record directly - int rc = sqlite3_exec(db, - "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 123), 'test/path', 'test value', 10, 'ctx1', strftime('%s','now'));", - NULL, NULL, NULL); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash = printf('%016x', 801);", &count); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); - // Verify it's there - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 801);", &count); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(count, 1); - // Verify created_at was set - sqlite3_int64 created_at; - rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash = printf('%016x', 123);", &created_at); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = printf('%016x', 801);", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(created_at > 0); // Should be a valid Unix timestamp + ASSERT_EQ(count, 1); sqlite3_close(db); } -TEST(sqlite_memory_delete_direct) { +TEST(sqlite_memory_delete_file_missing) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 1; + int rc = exec_get_int(db, "SELECT memory_delete_file('missing.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_file_matches_source_path) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert a test record directly int rc = sqlite3_exec(db, - "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 456), 'test/path2', 'test value 2', 12, 'ctx2', strftime('%s','now'));", + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 802), 'docs/delete-source.md', 'delete me', 9, 'ctx', 0);" + "INSERT INTO dbmem_content_source (path, source_path) VALUES " + "('docs/delete-source.md', '/tmp/delete-source.md');" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " + "(printf('%016x', 802), 0, X'00000000', 0, 9);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Delete it - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 456));", &result); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_delete_file('/tmp/delete-source.md');", &result); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); // Should have deleted 1 row + ASSERT_EQ(result, 1); - // Verify it's gone - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash = printf('%016x', 802);", &count); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(count, 0); sqlite3_close(db); } -TEST(sqlite_memory_delete_context_direct) { +TEST(sqlite_memory_delete_file_rejects_ambiguous_path) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert test records with different contexts int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(printf('%016x', 100), 'path1', 'v1', 2, 'ctx_a', 0), " - "(printf('%016x', 101), 'path2', 'v2', 2, 'ctx_a', 0), " - "(printf('%016x', 102), 'path3', 'v3', 2, 'ctx_b', 0);", + "(printf('%016x', 803), 'shared.md', 'one', 3, 'ctx', 0), " + "(printf('%016x', 804), 'other.md', 'two', 3, 'ctx', 0);" + "INSERT INTO dbmem_content_source (path, source_path) VALUES " + "('shared.md', '/tmp/one.md'), " + "('other.md', 'shared.md');", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Delete context 'ctx_a' - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete_context('ctx_a');", &result); + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_delete_file('shared.md');", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 2); // Should have deleted 2 rows + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + const char *msg = sqlite3_errmsg(db); + ASSERT(strstr(msg, "matched more than one row") != NULL); + sqlite3_finalize(stmt); - // Verify only ctx_b remains - sqlite3_int64 count; + sqlite3_int64 count = 0; rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); + ASSERT_EQ(count, 2); - // Verify ctx_b is the remaining one - char context[64]; - rc = exec_get_text(db, "SELECT context FROM dbmem_content;", context, sizeof(context)); + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_file_invalid_path) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_delete_file('');", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_STR_EQ(context, "ctx_b"); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_delete_file(123);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); sqlite3_close(db); } -TEST(sqlite_memory_clear_direct) { +TEST(sqlite_memory_delete_file_removes_directory_marker_only) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert test records int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(printf('%016x', 200), 'p1', 'v1', 2, 'c1', 0), " - "(printf('%016x', 201), 'p2', 'v2', 2, 'c2', 0);", + "(printf('%016x', 805), 'dirname/', '', 0, NULL, 0), " + "(printf('%016x', 806), 'dirname/file.md', 'content', 7, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Clear all - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_clear();", &result); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_delete_file('dirname');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); - // Verify all gone - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/';", &result); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/file.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); sqlite3_close(db); } -TEST(sqlite_memory_delete_with_vault_data) { +TEST(sqlite_memory_rename_file_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert into content and vault tables int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 300), 'path300', 'value', 5, 'ctx', 0);", - NULL, NULL, NULL); - ASSERT_EQ(rc, SQLITE_OK); - - rc = sqlite3_exec(db, + "VALUES (printf('%016x', 780), 'docs/old.md', 'content', 7, 'ctx', 0);" "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " - "VALUES (printf('%016x', 300), 0, X'00000000', 0, 5), (printf('%016x', 300), 1, X'00000000', 5, 5);", + "VALUES (printf('%016x', 780), 0, X'00000000', 0, 7);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Verify vault has data - sqlite3_int64 vault_count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 300);", &vault_count); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_rename_file('docs/old.md', 'docs/new.md');", &result); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(vault_count, 2); + ASSERT_EQ(result, 1); - // Delete by hash - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 300));", &result); + char path[64]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content WHERE hash = printf('%016x', 780);", path, sizeof(path)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT_STR_EQ(path, "docs/new.md"); - // Verify content is gone - sqlite3_int64 content_count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash = printf('%016x', 300);", &content_count); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'docs/old.md';", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(content_count, 0); + ASSERT_EQ(count, 0); - // Verify vault is also gone - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 300);", &vault_count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 780);", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(vault_count, 0); + ASSERT_EQ(count, 1); sqlite3_close(db); } -TEST(sqlite_memory_delete_twice) { +TEST(sqlite_memory_rename_file_directory_marker) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert a record int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 400), 'path400', 'value', 5, 'ctx', 0);", + "VALUES (printf('%016x', 783), 'old-dir/', '', 0, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Delete first time - should return 1 - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 400));", &result); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_rename_file('old-dir/', 'new-dir/');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); - // Delete second time - should return 0 - rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 400));", &result); + char path[64]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content WHERE hash = printf('%016x', 783);", path, sizeof(path)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); + ASSERT_STR_EQ(path, "new-dir/"); sqlite3_close(db); } -TEST(sqlite_memory_delete_context_null) { +TEST(sqlite_memory_rename_file_rejects_marker_file_conversion) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert records - some with NULL context, some with context int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(printf('%016x', 500), 'p1', 'v1', 2, NULL, 0), " - "(printf('%016x', 501), 'p2', 'v2', 2, NULL, 0), " - "(printf('%016x', 502), 'p3', 'v3', 2, 'has_context', 0);", + "(printf('%016x', 784), 'dir/', '', 0, NULL, 0), " + "(printf('%016x', 785), 'file.md', 'content', 7, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Verify 3 records - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 3); - - // Delete NULL context - function expects TEXT, so NULL returns error - // This is expected behavior - use empty string '' for records with no context sqlite3_stmt *stmt = NULL; - rc = sqlite3_prepare_v2(db, "SELECT memory_delete_context(NULL);", -1, &stmt, NULL); + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('dir/', 'fileish');", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_step(stmt); - // Should return error because NULL is not TEXT type ASSERT(rc == SQLITE_ERROR); sqlite3_finalize(stmt); - // Verify records are still there (nothing was deleted) - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('file.md', 'dirish/');", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 3); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); sqlite3_close(db); } -TEST(sqlite_memory_delete_wrong_type) { +TEST(sqlite_memory_rename_file_matches_source_path) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Try to call memory_delete with an invalid hash string - sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(db, "SELECT memory_delete('not_a_number');", -1, &stmt, NULL); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 782), 'docs/source-old.md', 'content', 7, 'ctx', 0);" + "INSERT INTO dbmem_content_source (path, source_path) " + "VALUES ('docs/source-old.md', '/tmp/source-old.md');", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - rc = sqlite3_step(stmt); - // Should return an error - ASSERT(rc == SQLITE_ERROR || rc == SQLITE_ROW); // Implementation may vary - sqlite3_finalize(stmt); + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_rename_file('/tmp/source-old.md', 'docs/source-new.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char path[64]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content WHERE hash = printf('%016x', 782);", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path, "docs/source-new.md"); + + char source_path[64]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source WHERE path = 'docs/source-new.md';", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, "/tmp/source-old.md"); sqlite3_close(db); } -TEST(sqlite_memory_delete_context_wrong_type) { +TEST(sqlite_memory_rename_file_missing) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Try to call memory_delete_context with INTEGER instead of TEXT - sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(db, "SELECT memory_delete_context(12345);", -1, &stmt, NULL); + sqlite3_int64 result = 1; + int rc = exec_get_int(db, "SELECT memory_rename_file('missing.md', 'new.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_duplicate_path) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 790), 'docs/a.md', 'a', 1, NULL, 0), " + "(printf('%016x', 791), 'docs/b.md', 'b', 1, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('docs/a.md', 'docs/b.md');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); rc = sqlite3_step(stmt); - // Should return an error - ASSERT(rc == SQLITE_ERROR || rc == SQLITE_ROW); // Implementation may vary + ASSERT(rc == SQLITE_ERROR); sqlite3_finalize(stmt); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('docs/a.md', 'docs/b.md');", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + sqlite3_close(db); } -TEST(sqlite_memory_update_access_setting) { +TEST(sqlite_memory_rename_file_rejects_ambiguous_path) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Set update_access to 0 - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_set_option('update_access', 0);", &result); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 792), 'shared.md', 'one', 3, NULL, 0), " + "(printf('%016x', 793), 'other.md', 'two', 3, NULL, 0);" + "INSERT INTO dbmem_content_source (path, source_path) VALUES " + "('shared.md', '/tmp/one.md'), " + "('other.md', 'shared.md');", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); - // Get the setting back - rc = exec_get_int(db, "SELECT memory_get_option('update_access');", &result); + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('shared.md', 'renamed.md');", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + const char *msg = sqlite3_errmsg(db); + ASSERT(strstr(msg, "matched more than one row") != NULL); + sqlite3_finalize(stmt); - // Set it back to 1 - rc = exec_get_int(db, "SELECT memory_set_option('update_access', 1);", &result); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('shared.md', 'other.md');", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT_EQ(count, 2); - // Verify - rc = exec_get_int(db, "SELECT memory_get_option('update_access');", &result); + sqlite3_close(db); +} + +#ifdef _WIN32 +#define DBMEM_TEST_ABS_ROOT "C:\\dbmem\\project\\" +#else +#define DBMEM_TEST_ABS_ROOT "/tmp/dbmem/project/" +#endif + +TEST(sqlite_memory_list_files_empty) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + char json[128]; + int rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[]}"); sqlite3_close(db); } -TEST(sqlite_memory_created_at_valid_range) { +TEST(sqlite_memory_list_files_strips_common_full_path) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert with current timestamp int rc = sqlite3_exec(db, - "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 600), 'path600', 'value', 5, 'ctx', strftime('%s','now'));", + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 710), '" DBMEM_TEST_ABS_ROOT "zeta.md', 'v1', 2, NULL, 0), " + "(printf('%016x', 711), '" DBMEM_TEST_ABS_ROOT "docs/nested/beta.md', 'v2', 2, NULL, 0), " + "(printf('%016x', 712), '" DBMEM_TEST_ABS_ROOT "docs/alpha.md', 'v3', 2, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Get the created_at value - sqlite3_int64 created_at; - rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash = printf('%016x', 600);", &created_at); + char json[1024]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"docs\",\"path\":\"docs\",\"children\":[{\"type\":\"directory\",\"name\":\"nested\",\"path\":\"docs/nested\",\"children\":[{\"type\":\"file\",\"name\":\"beta.md\",\"path\":\"docs/nested/beta.md\",\"indexed\":false}]},{\"type\":\"file\",\"name\":\"alpha.md\",\"path\":\"docs/alpha.md\",\"indexed\":false}]},{\"type\":\"file\",\"name\":\"zeta.md\",\"path\":\"zeta.md\",\"indexed\":false}]}"); - // Should be greater than 0 - ASSERT(created_at > 0); + sqlite3_close(db); +} - // Should be a reasonable Unix timestamp (after year 2020 = 1577836800) - ASSERT(created_at > 1577836800); +TEST(sqlite_memory_list_files_keeps_relative_paths) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); - // Should not be in the future (give 60 seconds buffer) - sqlite3_int64 now; - rc = exec_get_int(db, "SELECT strftime('%s','now');", &now); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 720), 'notes/zeta.md', 'v1', 2, NULL, 0), " + "(printf('%016x', 721), 'notes/docs/alpha.md', 'v2', 2, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(created_at <= now + 60); + + char json[1024]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"notes\",\"path\":\"notes\",\"children\":[{\"type\":\"directory\",\"name\":\"docs\",\"path\":\"notes/docs\",\"children\":[{\"type\":\"file\",\"name\":\"alpha.md\",\"path\":\"notes/docs/alpha.md\",\"indexed\":false}]},{\"type\":\"file\",\"name\":\"zeta.md\",\"path\":\"notes/zeta.md\",\"indexed\":false}]}]}"); sqlite3_close(db); } -TEST(sqlite_memory_clear_with_vault_fts) { +TEST(sqlite_memory_list_files_strips_single_full_path_directory) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert into all tables int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (printf('%016x', 700), 'path700', 'value', 5, 'ctx', 0);", + "VALUES (printf('%016x', 730), '" DBMEM_TEST_ABS_ROOT "docs/readme.md', 'v1', 2, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - rc = sqlite3_exec(db, - "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " - "VALUES (printf('%016x', 700), 0, X'00000000', 0, 5);", - NULL, NULL, NULL); + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"file\",\"name\":\"readme.md\",\"path\":\"readme.md\",\"indexed\":false}]}"); - rc = sqlite3_exec(db, - "INSERT INTO dbmem_vault_fts (content, hash, seq, context) " - "VALUES ('test content', printf('%016x', 700), 0, 'ctx');", + sqlite3_close(db); +} + +TEST(sqlite_memory_list_files_normalizes_windows_separators) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 740), 'C:\\dbmem\\project\\docs\\beta.md', 'v1', 2, NULL, 0), " + "(printf('%016x', 741), 'C:\\dbmem\\project\\alpha.md', 'v2', 2, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Clear all - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_clear();", &result); + char json[1024]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"docs\",\"path\":\"docs\",\"children\":[{\"type\":\"file\",\"name\":\"beta.md\",\"path\":\"docs/beta.md\",\"indexed\":false}]},{\"type\":\"file\",\"name\":\"alpha.md\",\"path\":\"alpha.md\",\"indexed\":false}]}"); - // Verify all tables are empty - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); + sqlite3_close(db); +} - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); +TEST(sqlite_memory_list_files_does_not_strip_mixed_path_types) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 750), '/tmp/dbmem/project/readme.md', 'v1', 2, NULL, 0), " + "(printf('%016x', 751), 'notes/alpha.md', 'v2', 2, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &count); + char json[2048]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"notes\",\"path\":\"notes\",\"children\":[{\"type\":\"file\",\"name\":\"alpha.md\",\"path\":\"notes/alpha.md\",\"indexed\":false}]},{\"type\":\"directory\",\"name\":\"tmp\",\"path\":\"/tmp\",\"children\":[{\"type\":\"directory\",\"name\":\"dbmem\",\"path\":\"/tmp/dbmem\",\"children\":[{\"type\":\"directory\",\"name\":\"project\",\"path\":\"/tmp/dbmem/project\",\"children\":[{\"type\":\"file\",\"name\":\"readme.md\",\"path\":\"/tmp/dbmem/project/readme.md\",\"indexed\":false}]}]}]}]}"); sqlite3_close(db); } -// Helper to insert a fake dbmem_content entry with a known path, hash, and length -static int insert_fake_content(sqlite3 *db, uint64_t hash, const char *path, const char *context, sqlite3_int64 length) { - sqlite3_stmt *vm = NULL; - const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " - "VALUES (?1, ?2, 'fake', ?3, ?4, 0);"; - int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); - if (rc != SQLITE_OK) return rc; - char hash_text[DBMEM_HASH_STR_MAXLEN]; - sqlite3_bind_text(vm, 1, dbmem_hash_to_hex(hash, hash_text), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); - sqlite3_bind_int64(vm, 3, length); - if (context) sqlite3_bind_text(vm, 4, context, -1, SQLITE_STATIC); - else sqlite3_bind_null(vm, 4); - rc = sqlite3_step(vm); - sqlite3_finalize(vm); - return (rc == SQLITE_DONE) ? SQLITE_OK : rc; -} - -TEST(sqlite_sync_directory_removes_deleted) { - // Test that memory_add_directory removes entries for files no longer on disk +TEST(sqlite_memory_list_files_omits_empty_paths) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - const char *test_dir = "/tmp/dbmem_test_sync_del"; - const char *file_keep = "/tmp/dbmem_test_sync_del/keep.md"; + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 760), '', 'text memory', 11, NULL, 0), " + "(printf('%016x', 761), 'docs/alpha.md', 'v2', 2, NULL, 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); - // Clean up - remove(file_keep); - remove("/tmp/dbmem_test_sync_del/gone.md"); - rmdir_p(test_dir); + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"docs\",\"path\":\"docs\",\"children\":[{\"type\":\"file\",\"name\":\"alpha.md\",\"path\":\"docs/alpha.md\",\"indexed\":false}]}]}"); - // Create directory with one file - mkdir_p(test_dir); - create_test_file(file_keep, "# Keep me\nStill here."); + sqlite3_close(db); +} - // Pre-insert entries: one for the existing file (with correct hash), - // and one for a file that no longer exists - int64_t len = 0; - char *buf = dbmem_file_read(file_keep, &len); - ASSERT(buf != NULL); - uint64_t keep_hash = dbmem_hash_compute(buf, (size_t)len); - dbmemory_free(buf); +TEST(sqlite_memory_list_files_escapes_json_strings) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); - int rc = insert_fake_content(db, keep_hash, file_keep, NULL, len); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 770), 'docs/a\"b.md', 'v1', 2, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 99999, "/tmp/dbmem_test_sync_del/gone.md", NULL, 4); + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"docs\",\"path\":\"docs\",\"children\":[{\"type\":\"file\",\"name\":\"a\\\"b.md\",\"path\":\"docs/a\\\"b.md\",\"indexed\":false}]}]}"); - // Verify 2 entries before sync - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 2); + sqlite3_close(db); +} - // Sync — should remove the entry for gone.md, skip keep.md (hash match) - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_add_directory('/tmp/dbmem_test_sync_del');", &result); - ASSERT_EQ(rc, SQLITE_OK); +TEST(sqlite_memory_list_files_includes_empty_directory_marker) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); - // Only keep.md entry should remain - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 771), 'dirname/', '', 0, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); - char path[256]; - rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(strstr(path, "keep.md") != NULL); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"dirname\",\"path\":\"dirname\",\"children\":[]}]}"); - remove(file_keep); - rmdir_p(test_dir); sqlite3_close(db); } -TEST(sqlite_sync_directory_removes_all_deleted) { - // Test sync on a directory where ALL previously indexed files were deleted +TEST(sqlite_memory_list_files_merges_directory_marker_with_children) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - const char *test_dir = "/tmp/dbmem_test_sync_allgone"; - remove("/tmp/dbmem_test_sync_allgone/x.md"); - rmdir_p(test_dir); - mkdir_p(test_dir); // empty directory - - // Insert fake entries pointing to files that don't exist - int rc = insert_fake_content(db, 1001, "/tmp/dbmem_test_sync_allgone/a.md", "ctx", 4); - ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 1002, "/tmp/dbmem_test_sync_allgone/b.md", "ctx", 4); - ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 1003, "/tmp/dbmem_test_sync_allgone/c.md", "ctx", 4); - ASSERT_EQ(rc, SQLITE_OK); - - // Also insert vault entries to verify cascade delete - rc = sqlite3_exec(db, - "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " - "(printf('%016x', 1001), 0, X'00000000', 0, 4), " - "(printf('%016x', 1002), 0, X'00000000', 0, 4), " - "(printf('%016x', 1003), 0, X'00000000', 0, 4);", + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 772), 'dirname/', '', 0, NULL, 0), " + "(printf('%016x', 773), 'dirname/file.md', 'content', 7, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 3); - - // Sync — all files gone, all entries should be removed - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_add_directory('/tmp/dbmem_test_sync_allgone');", &result); - ASSERT_EQ(rc, SQLITE_OK); - - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); - - // Vault entries should also be gone - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"dirname\",\"path\":\"dirname\",\"children\":[{\"type\":\"file\",\"name\":\"file.md\",\"path\":\"dirname/file.md\",\"indexed\":false}]}]}"); - rmdir_p(test_dir); sqlite3_close(db); } -TEST(sqlite_sync_directory_skips_unchanged) { - // Test that sync skips files whose content hash hasn't changed +TEST(sqlite_memory_list_files_reports_indexed_flag) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - const char *test_dir = "/tmp/dbmem_test_sync_skip"; - const char *file = "/tmp/dbmem_test_sync_skip/note.md"; - const char *content = "# My Note\nSome content."; - - remove(file); - rmdir_p(test_dir); - mkdir_p(test_dir); - create_test_file(file, content); - - // Compute the hash and pre-insert the entry - uint64_t hash = dbmem_hash_compute(content, strlen(content)); - int rc = insert_fake_content(db, hash, file, "notes", (sqlite3_int64)strlen(content)); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 801), 'done.md', 'v1', 2, NULL, 0), " + "(printf('%016x', 802), 'todo.md', 'v2', 2, NULL, 0), " + "(printf('%016x', 803), 'empty.md', '', 0, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Sync — file exists with matching hash, should be skipped - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_add_directory('/tmp/dbmem_test_sync_skip', 'notes');", &result); + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length, n_tokens, truncated) " + "VALUES (printf('%016x', 801), 0, zeroblob(16), 0, 2, 1, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Entry still exists unchanged (no duplication) - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + char json[1024]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[" + "{\"type\":\"file\",\"name\":\"done.md\",\"path\":\"done.md\",\"indexed\":true}," + "{\"type\":\"file\",\"name\":\"empty.md\",\"path\":\"empty.md\",\"indexed\":true}," + "{\"type\":\"file\",\"name\":\"todo.md\",\"path\":\"todo.md\",\"indexed\":false}]}"); - remove(file); - rmdir_p(test_dir); sqlite3_close(db); } -TEST(sqlite_sync_directory_ignores_sibling_prefixes) { - sqlite3 *db = open_test_db(); - ASSERT(db != NULL); +TEST(sqlite_memory_materialize_files_creates_directories_and_files) { + const char *base = TEST_TMP_DIR "/dbmem_materialize"; + const char *docs = TEST_TMP_DIR "/dbmem_materialize/docs"; + const char *nested = TEST_TMP_DIR "/dbmem_materialize/docs/nested"; + const char *file1 = TEST_TMP_DIR "/dbmem_materialize/docs/nested/a.md"; + const char *file2 = TEST_TMP_DIR "/dbmem_materialize/root.md"; - const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_prefix"; - const char *target_file = TEST_TMP_DIR "/dbmem_test_sync_prefix/gone.md"; - const char *sibling_file = TEST_TMP_DIR "/dbmem_test_sync_prefix2/gone.md"; + remove_test_file(file1); + remove_test_file(file2); + rmdir_p(nested); + rmdir_p(docs); + rmdir_p(base); - remove_test_file(target_file); - remove_test_file(sibling_file); - rmdir_p(TEST_TMP_DIR "/dbmem_test_sync_prefix2"); - rmdir_p(test_dir); - mkdir_p(test_dir); + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); - int rc = insert_fake_content(db, 3001, target_file, NULL, 4); - ASSERT_EQ(rc, SQLITE_OK); - rc = insert_fake_content(db, 3002, sibling_file, NULL, 4); + char sql[2048]; + snprintf(sql, sizeof(sql), + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%%016x', 810), 'docs/nested/a.md', '# Nested\nContent from db.', 25, NULL, 0), " + "(printf('%%016x', 811), 'root.md', 'Root content', 12, NULL, 0);"); + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_prefix');", &result); + sqlite3_int64 result = 0; + snprintf(sql, sizeof(sql), "SELECT memory_materialize_files('%s');", base); + rc = exec_get_int(db, sql, &result); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); - sqlite3_int64 count = 0; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); + int64_t len = 0; + char *content = dbmem_file_read(file1, &len); + ASSERT(content != NULL); + ASSERT_STR_EQ(content, "# Nested\nContent from db."); + dbmemory_free(content); - char path[256]; - rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_STR_EQ(path, sibling_file); + content = dbmem_file_read(file2, &len); + ASSERT(content != NULL); + ASSERT_STR_EQ(content, "Root content"); + dbmemory_free(content); - rmdir_p(test_dir); sqlite3_close(db); + remove_test_file(file1); + remove_test_file(file2); + rmdir_p(nested); + rmdir_p(docs); + rmdir_p(base); } -TEST(sqlite_cache_table_exists) { +TEST(sqlite_memory_materialize_files_creates_directory_markers) { + const char *base = TEST_TMP_DIR "/dbmem_materialize_marker"; + const char *dirname = TEST_TMP_DIR "/dbmem_materialize_marker/dirname"; + + rmdir_p(dirname); + rmdir_p(base); + sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Check that dbmem_cache table exists - char sql[256]; - int rc = exec_get_text(db, - "SELECT sql FROM sqlite_master WHERE name='dbmem_cache';", - sql, sizeof(sql)); + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 815), 'dirname/', '', 0, NULL, 0);", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT(strstr(sql, "text_hash") != NULL); - ASSERT(strstr(sql, "text_hash TEXT NOT NULL") != NULL); - ASSERT(strstr(sql, "provider") != NULL); - ASSERT(strstr(sql, "model") != NULL); - ASSERT(strstr(sql, "embedding") != NULL); - ASSERT(strstr(sql, "dimension") != NULL); + + sqlite3_int64 result = 0; + char sql[1024]; + snprintf(sql, sizeof(sql), "SELECT memory_materialize_files('%s');", base); + rc = exec_get_int(db, sql, &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + ASSERT(dbmem_dir_exists(dirname)); sqlite3_close(db); + rmdir_p(dirname); + rmdir_p(base); } -TEST(sqlite_cache_clear_empty) { +TEST(sqlite_memory_materialize_files_accepts_existing_same_content) { + const char *root = TEST_TMP_DIR; + const char *file = TEST_TMP_DIR "/dbmem_materialize_existing.md"; + const char *content_text = "Already here."; + + remove_test_file(file); + ASSERT_EQ(create_test_file(file, content_text), 0); + sqlite3 *db = open_test_db(); ASSERT(db != NULL); - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + char sql[1024]; + snprintf(sql, sizeof(sql), + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%%016x', 812), 'dbmem_materialize_existing.md', '%s', 13, NULL, 0);", + content_text); + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); // No rows deleted from empty cache + + sqlite3_int64 result = 0; + snprintf(sql, sizeof(sql), "SELECT memory_materialize_files('%s');", root); + rc = exec_get_int(db, sql, &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + int64_t len = 0; + char *read_back = dbmem_file_read(file, &len); + ASSERT(read_back != NULL); + ASSERT_STR_EQ(read_back, content_text); + dbmemory_free(read_back); sqlite3_close(db); + remove_test_file(file); } -TEST(sqlite_cache_clear_with_data) { +TEST(sqlite_memory_materialize_files_rejects_parent_segments) { + const char *root = TEST_TMP_DIR "/dbmem_materialize_safe"; + const char *escaped = TEST_TMP_DIR "/dbmem_materialize_escape.md"; + + remove_test_file(escaped); + rmdir_p(root); + mkdir_p(root); + sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert some fake cache entries int rc = sqlite3_exec(db, - "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(printf('%016x', 111), 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(printf('%016x', 222), 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(printf('%016x', 333), 'local', 'nomic', X'00000000', 1);", + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 814), '../dbmem_materialize_escape.md', 'escape', 6, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Clear all - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + char sql[1024]; + snprintf(sql, sizeof(sql), "SELECT memory_materialize_files('%s');", root); + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 3); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); - // Verify empty - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 0); + ASSERT(!dbmem_file_exists(escaped)); sqlite3_close(db); + rmdir_p(root); } -TEST(sqlite_cache_clear_by_provider_model) { +TEST(sqlite_memory_materialize_files_rejects_null_content) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert cache entries for different provider/model combos int rc = sqlite3_exec(db, - "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(printf('%016x', 111), 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(printf('%016x', 222), 'openai', 'text-embedding-3-small', X'00000000', 1), " - "(printf('%016x', 333), 'local', 'nomic', X'00000000', 1);", + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 813), 'missing-content.md', NULL, 0, NULL, 0);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Clear only openai entries - sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_cache_clear('openai', 'text-embedding-3-small');", &result); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 2); - - // Verify only local entry remains - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); - - char provider[64]; - rc = exec_get_text(db, "SELECT provider FROM dbmem_cache;", provider, sizeof(provider)); + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_materialize_files();", -1, &stmt, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_STR_EQ(provider, "local"); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); sqlite3_close(db); } -TEST(sqlite_cache_setting_default) { +TEST(sqlite_schema_has_timestamps) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // embedding_cache should default to enabled (not in settings table, but enabled in context) - // Set it to 0, read back, set to 1, read back - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_set_option('embedding_cache', 0);", &result); + // Check that schema includes created_at column + char sql[512]; + int rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_content';", + sql, sizeof(sql)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT(strstr(sql, "hash TEXT PRIMARY KEY NOT NULL") != NULL); + ASSERT(strstr(sql, "source_path") == NULL); + ASSERT(strstr(sql, "created_at") != NULL); + ASSERT(strstr(sql, "last_accessed") != NULL); - rc = exec_get_int(db, "SELECT memory_get_option('embedding_cache');", &result); + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_content_source';", + sql, sizeof(sql)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); + ASSERT(strstr(sql, "path TEXT PRIMARY KEY NOT NULL") != NULL); + ASSERT(strstr(sql, "source_path") != NULL); - rc = exec_get_int(db, "SELECT memory_set_option('embedding_cache', 1);", &result); + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_vault';", + sql, sizeof(sql)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT(strstr(sql, "hash TEXT NOT NULL") != NULL); + ASSERT(strstr(sql, "n_tokens") != NULL); + ASSERT(strstr(sql, "truncated") != NULL); - rc = exec_get_int(db, "SELECT memory_get_option('embedding_cache');", &result); + rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_cache';", + sql, sizeof(sql)); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT(strstr(sql, "text_hash TEXT NOT NULL") != NULL); + ASSERT(strstr(sql, "n_tokens") != NULL); + ASSERT(strstr(sql, "truncated") != NULL); + + sqlite3_int64 schema_version = 0; + rc = exec_get_int(db, "SELECT value FROM dbmem_settings WHERE key = 'schema_version';", &schema_version); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(schema_version, 4); sqlite3_close(db); } -TEST(sqlite_cache_max_entries_setting) { - sqlite3 *db = open_test_db(); - ASSERT(db != NULL); - - // Default is 0 (no limit) - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 100);", &result); +TEST(sqlite_schema_migrates_embedding_metadata) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); - rc = exec_get_int(db, "SELECT memory_get_option('cache_max_entries');", &result); + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "INSERT INTO dbmem_settings (key, value) VALUES ('schema_version', '1');" + "CREATE TABLE dbmem_vault (hash TEXT NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY (hash, seq));" + "CREATE TABLE dbmem_cache (text_hash TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, PRIMARY KEY (text_hash, provider, model));", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 100); - // Set back to 0 (no limit) - rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 0);", &result); + rc = sqlite3_memory_init(db, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); - rc = exec_get_int(db, "SELECT memory_get_option('cache_max_entries');", &result); + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_vault') WHERE name = 'n_tokens';", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); + ASSERT_EQ(count, 1); - sqlite3_close(db); -} + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_vault') WHERE name = 'truncated';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); -TEST(sqlite_cache_eviction) { - sqlite3 *db = open_test_db(); - ASSERT(db != NULL); + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_cache') WHERE name = 'n_tokens';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); - // Set max entries to 3 - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 3);", &result); + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_cache') WHERE name = 'truncated';", &count); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); - // Insert 5 entries (rowids 1-5) rc = sqlite3_exec(db, - "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(printf('%016x', 1), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 2), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 3), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 4), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 5), 'p', 'm', X'00000000', 1);", + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES (printf('%016x', 900), 0, X'00000000', 0, 4);" + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES (printf('%016x', 901), 'dummy', 'model', X'00000000', 1);", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Verify 5 entries before any sync triggers eviction - sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_vault WHERE hash = printf('%016x', 900);", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 5); + ASSERT_EQ(count, 0); - // Now manually call memory_cache_clear to clear all, then re-insert within limit - rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + rc = exec_get_int(db, "SELECT truncated FROM dbmem_cache WHERE text_hash = printf('%016x', 901);", &count); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); - // Insert exactly 3 (at limit) - rc = sqlite3_exec(db, - "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(printf('%016x', 10), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 11), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 12), 'p', 'm', X'00000000', 1);", + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_content') WHERE name = 'source_path';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dbmem_content_source';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT value FROM dbmem_settings WHERE key = 'schema_version';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 4); + + sqlite3_close(db); +} + +TEST(sqlite_schema_migrates_source_path_to_local_table) { + sqlite3 *db = NULL; + int rc = sqlite3_open(":memory:", &db); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "CREATE TABLE dbmem_settings (key TEXT PRIMARY KEY, value TEXT);" + "INSERT INTO dbmem_settings (key, value) VALUES ('schema_version', '3');" + "CREATE TABLE dbmem_content (hash TEXT PRIMARY KEY NOT NULL, path TEXT NOT NULL DEFAULT '' UNIQUE, source_path TEXT DEFAULT NULL, value TEXT DEFAULT NULL, length INTEGER NOT NULL DEFAULT 0, context TEXT DEFAULT NULL, created_at INTEGER DEFAULT 0, last_accessed INTEGER DEFAULT 0);" + "INSERT INTO dbmem_content (hash, path, source_path, value, length, context, created_at, last_accessed) " + "VALUES (printf('%016x', 998), 'docs/local.md', '/tmp/local.md', 'content', 7, 'ctx', 11, 12);" + "CREATE TABLE dbmem_vault (hash TEXT NOT NULL, seq INTEGER NOT NULL, embedding BLOB NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, n_tokens INTEGER NOT NULL DEFAULT 0, truncated INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (hash, seq));" + "CREATE TABLE dbmem_cache (text_hash TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, embedding BLOB NOT NULL, dimension INTEGER NOT NULL, n_tokens INTEGER NOT NULL DEFAULT 0, truncated INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (text_hash, provider, model));", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + rc = sqlite3_memory_init(db, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 3); + + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM pragma_table_info('dbmem_content') WHERE name = 'source_path';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + char source_path[64]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source WHERE path = 'docs/local.md';", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, "/tmp/local.md"); + + rc = exec_get_int(db, "SELECT value FROM dbmem_settings WHERE key = 'schema_version';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 4); sqlite3_close(db); } -TEST(sqlite_cache_no_eviction_when_unlimited) { +// Test that inserting directly into tables works with new schema +TEST(sqlite_direct_insert_with_timestamp) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Default cache_max_entries is 0 (no limit) - // Insert many entries, none should be evicted + // Insert a test record directly int rc = sqlite3_exec(db, - "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " - "(printf('%016x', 1), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 2), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 3), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 4), 'p', 'm', X'00000000', 1), " - "(printf('%016x', 5), 'p', 'm', X'00000000', 1);", + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 123), 'test/path', 'test value', 10, 'ctx1', strftime('%s','now'));", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); + // Verify it's there sqlite3_int64 count; - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 5); + ASSERT_EQ(count, 1); + + // Verify created_at was set + sqlite3_int64 created_at; + rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash = printf('%016x', 123);", &created_at); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(created_at > 0); // Should be a valid Unix timestamp sqlite3_close(db); } -TEST(sqlite_search_oversample_setting) { +TEST(sqlite_memory_delete_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Default is 0 (no oversampling) - sqlite3_int64 result; - int rc = exec_get_int(db, "SELECT memory_set_option('search_oversample', 4);", &result); + // Insert a test record directly + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 456), 'test/path2', 'test value 2', 12, 'ctx2', strftime('%s','now'));" + "INSERT INTO dbmem_content_source (path, source_path) " + "VALUES ('test/path2', '/tmp/test/path2');", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); - rc = exec_get_int(db, "SELECT memory_get_option('search_oversample');", &result); + // Delete it + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 456));", &result); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 4); + ASSERT_EQ(result, 1); // Should have deleted 1 row - // Set back to 0 (no oversampling) - rc = exec_get_int(db, "SELECT memory_set_option('search_oversample', 0);", &result); + // Verify it's gone + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 1); + ASSERT_EQ(count, 0); - rc = exec_get_int(db, "SELECT memory_get_option('search_oversample');", &result); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content_source;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(result, 0); + ASSERT_EQ(count, 0); sqlite3_close(db); } -TEST(sqlite_search_zero_value_settings_apply_to_context) { +TEST(sqlite_memory_delete_context_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - sqlite3_int64 result = 0; - int rc = exec_get_int(db, "SELECT memory_set_option('max_results', 0);", &result); + // Insert test records with different contexts + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 100), 'path1', 'v1', 2, 'ctx_a', 0), " + "(printf('%016x', 101), 'path2', 'v2', 2, 'ctx_a', 0), " + "(printf('%016x', 102), 'path3', 'v3', 2, 'ctx_b', 0);" + "INSERT INTO dbmem_content_source (path, source_path) VALUES " + "('path1', '/tmp/path1'), " + "('path2', '/tmp/path2'), " + "('path3', '/tmp/path3');", + NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - rc = exec_get_int(db, "SELECT memory_set_option('vector_weight', 0.0);", &result); + + // Delete context 'ctx_a' + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_delete_context('ctx_a');", &result); ASSERT_EQ(rc, SQLITE_OK); - rc = exec_get_int(db, "SELECT memory_set_option('text_weight', 0.0);", &result); + ASSERT_EQ(result, 2); // Should have deleted 2 rows + + // Verify only ctx_b remains + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); ASSERT_EQ(rc, SQLITE_OK); - rc = exec_get_int(db, "SELECT memory_set_option('min_score', 0.0);", &result); + ASSERT_EQ(count, 1); + + // Verify ctx_b is the remaining one + char context[64]; + rc = exec_get_text(db, "SELECT context FROM dbmem_content;", context, sizeof(context)); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(context, "ctx_b"); - dbmem_context *ctx = get_test_ctx(db); - ASSERT(ctx != NULL); - ASSERT_EQ(dbmem_context_max_results(ctx), 0); - ASSERT_EQ(dbmem_context_vector_weight(ctx), 0.0); - ASSERT_EQ(dbmem_context_text_weight(ctx), 0.0); - ASSERT_EQ(dbmem_context_min_score(ctx), 0.0); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content_source;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char source_path[64]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source;", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, "/tmp/path3"); sqlite3_close(db); } -TEST(sqlite_memory_delete_context_with_vault) { +TEST(sqlite_memory_clear_direct) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - // Insert records with different contexts into content and vault + // Insert test records int rc = sqlite3_exec(db, "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " - "(printf('%016x', 800), 'p1', 'v1', 2, 'delete_me', 0), " - "(printf('%016x', 801), 'p2', 'v2', 2, 'keep_me', 0);", - NULL, NULL, NULL); - ASSERT_EQ(rc, SQLITE_OK); - - rc = sqlite3_exec(db, - "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " - "(printf('%016x', 800), 0, X'00000000', 0, 2), " - "(printf('%016x', 801), 0, X'00000000', 0, 2);", + "(printf('%016x', 200), 'p1', 'v1', 2, 'c1', 0), " + "(printf('%016x', 201), 'p2', 'v2', 2, 'c2', 0);" + "INSERT INTO dbmem_content_source (path, source_path) VALUES " + "('p1', '/tmp/p1'), " + "('p2', '/tmp/p2');", NULL, NULL, NULL); ASSERT_EQ(rc, SQLITE_OK); - // Delete context 'delete_me' + // Clear all sqlite3_int64 result; - rc = exec_get_int(db, "SELECT memory_delete_context('delete_me');", &result); + rc = exec_get_int(db, "SELECT memory_clear();", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); - // Verify content: only 'keep_me' remains + // Verify all gone sqlite3_int64 count; rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); - - // Verify vault: only hash 801 remains - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); - ASSERT_EQ(rc, SQLITE_OK); - ASSERT_EQ(count, 1); + ASSERT_EQ(count, 0); - char hash[64]; - rc = exec_get_text(db, "SELECT hash FROM dbmem_vault;", hash, sizeof(hash)); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content_source;", &count); ASSERT_EQ(rc, SQLITE_OK); - ASSERT_STR_EQ(hash, "0000000000000321"); + ASSERT_EQ(count, 0); sqlite3_close(db); } -// ============================================================================ -// Custom Provider Tests -// ============================================================================ -// Dummy embedding engine for testing -typedef struct { - float embedding[4]; - int dimension; - int compute_count; - char api_key[256]; -} dummy_engine_t; +TEST(sqlite_memory_delete_with_vault_data) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); -static void *dummy_init(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { - UNUSED_PARAM(model); - UNUSED_PARAM(xdata); - dummy_engine_t *e = (dummy_engine_t *)calloc(1, sizeof(dummy_engine_t)); - if (!e) { snprintf(err_msg, 1024, "alloc failed"); return NULL; } - e->dimension = 4; - e->embedding[0] = 0.1f; - e->embedding[1] = 0.2f; - e->embedding[2] = 0.3f; - e->embedding[3] = 0.4f; - if (api_key) strncpy(e->api_key, api_key, sizeof(e->api_key) - 1); - return e; -} + // Insert into content and vault tables + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 300), 'path300', 'value', 5, 'ctx', 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); -static int dummy_compute(void *engine, const char *text, int text_len, void *xdata, dbmem_embedding_result_t *result) { - UNUSED_PARAM(text); - UNUSED_PARAM(text_len); - UNUSED_PARAM(xdata); - dummy_engine_t *e = (dummy_engine_t *)engine; - e->compute_count++; - result->n_tokens = text_len / 4; - result->n_tokens_truncated = 0; - result->n_embd = e->dimension; - result->embedding = e->embedding; - return 0; -} + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " + "VALUES (printf('%016x', 300), 0, X'00000000', 0, 5), (printf('%016x', 300), 1, X'00000000', 5, 5);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); -static void dummy_free(void *engine, void *xdata) { - UNUSED_PARAM(xdata); - free(engine); -} + // Verify vault has data + sqlite3_int64 vault_count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 300);", &vault_count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(vault_count, 2); -static void *dummy_init_fail(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { - UNUSED_PARAM(model); - UNUSED_PARAM(api_key); - UNUSED_PARAM(xdata); - snprintf(err_msg, 1024, "intentional init failure"); - return NULL; -} + // Delete by hash + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 300));", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); -typedef struct { - int fail_after; - int calls; -} flaky_provider_state_t; + // Verify content is gone + sqlite3_int64 content_count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE hash = printf('%016x', 300);", &content_count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(content_count, 0); -static void *flaky_init(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { - flaky_provider_state_t *state = (flaky_provider_state_t *)xdata; - if (state) state->calls = 0; - return dummy_init(model, api_key, NULL, err_msg); -} + // Verify vault is also gone + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = printf('%016x', 300);", &vault_count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(vault_count, 0); -static int flaky_compute(void *engine, const char *text, int text_len, void *xdata, dbmem_embedding_result_t *result) { - flaky_provider_state_t *state = (flaky_provider_state_t *)xdata; - if (state) { - state->calls++; - if (state->calls >= state->fail_after) return -1; - } - return dummy_compute(engine, text, text_len, NULL, result); + sqlite3_close(db); } -TEST(sqlite_custom_provider_register) { +TEST(sqlite_memory_delete_twice) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; - int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + // Insert a record + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 400), 'path400', 'value', 5, 'ctx', 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Delete first time - should return 1 + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 400));", &result); ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + // Delete second time - should return 0 + rc = exec_get_int(db, "SELECT memory_delete(printf('%016x', 400));", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_context_null) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert records - some with NULL context, some with context + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 500), 'p1', 'v1', 2, NULL, 0), " + "(printf('%016x', 501), 'p2', 'v2', 2, NULL, 0), " + "(printf('%016x', 502), 'p3', 'v3', 2, 'has_context', 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Verify 3 records + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 3); + + // Delete NULL context - function expects TEXT, so NULL returns error + // This is expected behavior - use empty string '' for records with no context + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_delete_context(NULL);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + // Should return error because NULL is not TEXT type + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + // Verify records are still there (nothing was deleted) + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 3); + + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_wrong_type) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Try to call memory_delete with an invalid hash string + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_delete('not_a_number');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_step(stmt); + // Should return an error + ASSERT(rc == SQLITE_ERROR || rc == SQLITE_ROW); // Implementation may vary + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_context_wrong_type) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Try to call memory_delete_context with INTEGER instead of TEXT + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_delete_context(12345);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_step(stmt); + // Should return an error + ASSERT(rc == SQLITE_ERROR || rc == SQLITE_ROW); // Implementation may vary + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_update_access_setting) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Set update_access to 0 + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_set_option('update_access', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + // Get the setting back + rc = exec_get_int(db, "SELECT memory_get_option('update_access');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + // Set it back to 1 + rc = exec_get_int(db, "SELECT memory_set_option('update_access', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + // Verify + rc = exec_get_int(db, "SELECT memory_get_option('update_access');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_memory_created_at_valid_range) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert with current timestamp + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 600), 'path600', 'value', 5, 'ctx', strftime('%s','now'));", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Get the created_at value + sqlite3_int64 created_at; + rc = exec_get_int(db, "SELECT created_at FROM dbmem_content WHERE hash = printf('%016x', 600);", &created_at); + ASSERT_EQ(rc, SQLITE_OK); + + // Should be greater than 0 + ASSERT(created_at > 0); + + // Should be a reasonable Unix timestamp (after year 2020 = 1577836800) + ASSERT(created_at > 1577836800); + + // Should not be in the future (give 60 seconds buffer) + sqlite3_int64 now; + rc = exec_get_int(db, "SELECT strftime('%s','now');", &now); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(created_at <= now + 60); + + sqlite3_close(db); +} + +TEST(sqlite_memory_clear_with_vault_fts) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert into all tables + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (printf('%016x', 700), 'path700', 'value', 5, 'ctx', 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) " + "VALUES (printf('%016x', 700), 0, X'00000000', 0, 5);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault_fts (content, hash, seq, context) " + "VALUES ('test content', printf('%016x', 700), 0, 'ctx');", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Clear all + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + // Verify all tables are empty + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + sqlite3_close(db); +} + +// Helper to insert a fake dbmem_content entry with a known path, hash, and length +static int insert_fake_content(sqlite3 *db, uint64_t hash, const char *path, const char *context, sqlite3_int64 length) { + sqlite3_stmt *vm = NULL; + const char *sql = "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES (?1, ?2, 'fake', ?3, ?4, 0);"; + int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); + if (rc != SQLITE_OK) return rc; + char hash_text[DBMEM_HASH_STR_MAXLEN]; + sqlite3_bind_text(vm, 1, dbmem_hash_to_hex(hash, hash_text), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(vm, 2, path, -1, SQLITE_STATIC); + sqlite3_bind_int64(vm, 3, length); + if (context) sqlite3_bind_text(vm, 4, context, -1, SQLITE_STATIC); + else sqlite3_bind_null(vm, 4); + rc = sqlite3_step(vm); + sqlite3_finalize(vm); + return (rc == SQLITE_DONE) ? SQLITE_OK : rc; +} + +TEST(sqlite_sync_directory_removes_deleted) { + // Test that memory_add_directory removes entries for files no longer on disk + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_del"; + const char *file_keep = TEST_TMP_DIR "/dbmem_test_sync_del/keep.md"; + const char *file_gone = TEST_TMP_DIR "/dbmem_test_sync_del/gone.md"; + + // Clean up + remove(file_keep); + remove(file_gone); + rmdir_p(test_dir); + + // Create directory with one file + mkdir_p(test_dir); + create_test_file(file_keep, "# Keep me\nStill here."); + + // Pre-insert entries: one for the existing file (with correct hash), + // and one for a file that no longer exists + int64_t len = 0; + char *buf = dbmem_file_read(file_keep, &len); + ASSERT(buf != NULL); + uint64_t keep_hash = dbmem_hash_compute(buf, (size_t)len); + dbmemory_free(buf); + + int rc = insert_fake_content(db, keep_hash, file_keep, NULL, len); + ASSERT_EQ(rc, SQLITE_OK); + + rc = insert_fake_content(db, 99999, file_gone, NULL, 4); + ASSERT_EQ(rc, SQLITE_OK); + + // Verify 2 entries before sync + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + // Sync — should remove the entry for gone.md, skip keep.md (hash match) + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_del');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // Only keep.md entry should remain + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char path[256]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(path, "keep.md") != NULL); + + remove(file_keep); + rmdir_p(test_dir); + sqlite3_close(db); +} + +TEST(sqlite_sync_directory_removes_all_deleted) { + // Test sync on a directory where ALL previously indexed files were deleted + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_allgone"; + const char *file_a = TEST_TMP_DIR "/dbmem_test_sync_allgone/a.md"; + const char *file_b = TEST_TMP_DIR "/dbmem_test_sync_allgone/b.md"; + const char *file_c = TEST_TMP_DIR "/dbmem_test_sync_allgone/c.md"; + + remove(TEST_TMP_DIR "/dbmem_test_sync_allgone/x.md"); + rmdir_p(test_dir); + mkdir_p(test_dir); // empty directory + + // Insert fake entries pointing to files that don't exist + int rc = insert_fake_content(db, 1001, file_a, "ctx", 4); + ASSERT_EQ(rc, SQLITE_OK); + rc = insert_fake_content(db, 1002, file_b, "ctx", 4); + ASSERT_EQ(rc, SQLITE_OK); + rc = insert_fake_content(db, 1003, file_c, "ctx", 4); + ASSERT_EQ(rc, SQLITE_OK); + + // Also insert vault entries to verify cascade delete + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " + "(printf('%016x', 1001), 0, X'00000000', 0, 4), " + "(printf('%016x', 1002), 0, X'00000000', 0, 4), " + "(printf('%016x', 1003), 0, X'00000000', 0, 4);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 3); + + // Sync — all files gone, all entries should be removed + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_allgone');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + // Vault entries should also be gone + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rmdir_p(test_dir); + sqlite3_close(db); +} + +TEST(sqlite_sync_directory_skips_unchanged) { + // Test that sync skips files whose content hash hasn't changed + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_skip"; + const char *file = TEST_TMP_DIR "/dbmem_test_sync_skip/note.md"; + const char *content = "# My Note\nSome content."; + + remove(file); + rmdir_p(test_dir); + mkdir_p(test_dir); + create_test_file(file, content); + + // Compute the hash from disk so Windows text-mode newline translation + // cannot make the pre-inserted hash differ from memory_add_directory(). + int64_t len = 0; + char *buf = dbmem_file_read(file, &len); + ASSERT(buf != NULL); + uint64_t hash = dbmem_hash_compute(buf, (size_t)len); + dbmemory_free(buf); + + int rc = insert_fake_content(db, hash, file, "notes", len); + ASSERT_EQ(rc, SQLITE_OK); + + // Sync — file exists with matching hash, should be skipped + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_skip', 'notes');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // Entry still exists unchanged (no duplication) + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + remove(file); + rmdir_p(test_dir); + sqlite3_close(db); +} + +TEST(sqlite_sync_directory_ignores_sibling_prefixes) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_prefix"; + const char *target_file = TEST_TMP_DIR "/dbmem_test_sync_prefix/gone.md"; + const char *sibling_file = TEST_TMP_DIR "/dbmem_test_sync_prefix2/gone.md"; + + remove_test_file(target_file); + remove_test_file(sibling_file); + rmdir_p(TEST_TMP_DIR "/dbmem_test_sync_prefix2"); + rmdir_p(test_dir); + mkdir_p(test_dir); + + int rc = insert_fake_content(db, 3001, target_file, NULL, 4); + ASSERT_EQ(rc, SQLITE_OK); + rc = insert_fake_content(db, 3002, sibling_file, NULL, 4); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_prefix');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char path[256]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path, sibling_file); + + rmdir_p(test_dir); + sqlite3_close(db); +} + +TEST(sqlite_sync_directory_keeps_logical_rows_without_source_path) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *test_dir = TEST_TMP_DIR "/dbmem_test_sync_logical"; + rmdir_p(test_dir); + mkdir_p(test_dir); + + int rc = insert_fake_content(db, 4001, "logical-note-without-source", "ctx", 4); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = -1; + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_test_sync_logical');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'logical-note-without-source';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rmdir_p(test_dir); + sqlite3_close(db); +} + +TEST(sqlite_cache_table_exists) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Check that dbmem_cache table exists + char sql[512]; + int rc = exec_get_text(db, + "SELECT sql FROM sqlite_master WHERE name='dbmem_cache';", + sql, sizeof(sql)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(sql, "text_hash") != NULL); + ASSERT(strstr(sql, "text_hash TEXT NOT NULL") != NULL); + ASSERT(strstr(sql, "provider") != NULL); + ASSERT(strstr(sql, "model") != NULL); + ASSERT(strstr(sql, "embedding") != NULL); + ASSERT(strstr(sql, "dimension") != NULL); + ASSERT(strstr(sql, "n_tokens") != NULL); + ASSERT(strstr(sql, "truncated") != NULL); + + sqlite3_close(db); +} + +TEST(sqlite_cache_clear_empty) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); // No rows deleted from empty cache + + sqlite3_close(db); +} + +TEST(sqlite_cache_clear_with_data) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert some fake cache entries + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " + "(printf('%016x', 111), 'openai', 'text-embedding-3-small', X'00000000', 1), " + "(printf('%016x', 222), 'openai', 'text-embedding-3-small', X'00000000', 1), " + "(printf('%016x', 333), 'local', 'nomic', X'00000000', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Clear all + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 3); + + // Verify empty + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + sqlite3_close(db); +} + +TEST(sqlite_cache_clear_by_provider_model) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert cache entries for different provider/model combos + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " + "(printf('%016x', 111), 'openai', 'text-embedding-3-small', X'00000000', 1), " + "(printf('%016x', 222), 'openai', 'text-embedding-3-small', X'00000000', 1), " + "(printf('%016x', 333), 'local', 'nomic', X'00000000', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Clear only openai entries + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_cache_clear('openai', 'text-embedding-3-small');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + // Verify only local entry remains + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char provider[64]; + rc = exec_get_text(db, "SELECT provider FROM dbmem_cache;", provider, sizeof(provider)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(provider, "local"); + + sqlite3_close(db); +} + +TEST(sqlite_cache_setting_default) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // embedding_cache should default to enabled (not in settings table, but enabled in context) + // Set it to 0, read back, set to 1, read back + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_set_option('embedding_cache', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('embedding_cache');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT memory_set_option('embedding_cache', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('embedding_cache');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_cache_max_entries_setting) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Default is 0 (no limit) + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 100);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('cache_max_entries');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 100); + + // Set back to 0 (no limit) + rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('cache_max_entries');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_cache_eviction) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Set max entries to 3 + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_set_option('cache_max_entries', 3);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // Insert 5 entries (rowids 1-5) + rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " + "(printf('%016x', 1), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 2), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 3), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 4), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 5), 'p', 'm', X'00000000', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Verify 5 entries before any sync triggers eviction + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 5); + + // Now manually call memory_cache_clear to clear all, then re-insert within limit + rc = exec_get_int(db, "SELECT memory_cache_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // Insert exactly 3 (at limit) + rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " + "(printf('%016x', 10), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 11), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 12), 'p', 'm', X'00000000', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 3); + + sqlite3_close(db); +} + +TEST(sqlite_cache_no_eviction_when_unlimited) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Default cache_max_entries is 0 (no limit) + // Insert many entries, none should be evicted + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_cache (text_hash, provider, model, embedding, dimension) VALUES " + "(printf('%016x', 1), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 2), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 3), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 4), 'p', 'm', X'00000000', 1), " + "(printf('%016x', 5), 'p', 'm', X'00000000', 1);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 5); + + sqlite3_close(db); +} + +TEST(sqlite_search_oversample_setting) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Default is 0 (no oversampling) + sqlite3_int64 result; + int rc = exec_get_int(db, "SELECT memory_set_option('search_oversample', 4);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('search_oversample');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 4); + + // Set back to 0 (no oversampling) + rc = exec_get_int(db, "SELECT memory_set_option('search_oversample', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('search_oversample');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_search_zero_value_settings_apply_to_context) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('max_results', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('vector_weight', 0.0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('text_weight', 0.0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('min_score', 0.0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + dbmem_context *ctx = get_test_ctx(db); + ASSERT(ctx != NULL); + ASSERT_EQ(dbmem_context_max_results(ctx), 0); + ASSERT_EQ(dbmem_context_vector_weight(ctx), 0.0); + ASSERT_EQ(dbmem_context_text_weight(ctx), 0.0); + ASSERT_EQ(dbmem_context_min_score(ctx), 0.0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_delete_context_with_vault) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + // Insert records with different contexts into content and vault + int rc = sqlite3_exec(db, + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) VALUES " + "(printf('%016x', 800), 'p1', 'v1', 2, 'delete_me', 0), " + "(printf('%016x', 801), 'p2', 'v2', 2, 'keep_me', 0);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_exec(db, + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length) VALUES " + "(printf('%016x', 800), 0, X'00000000', 0, 2), " + "(printf('%016x', 801), 0, X'00000000', 0, 2);", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + + // Delete context 'delete_me' + sqlite3_int64 result; + rc = exec_get_int(db, "SELECT memory_delete_context('delete_me');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + // Verify content: only 'keep_me' remains + sqlite3_int64 count; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + // Verify vault: only hash 801 remains + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char hash[64]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_vault;", hash, sizeof(hash)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash, "0000000000000321"); + + sqlite3_close(db); +} +// ============================================================================ +// Custom Provider Tests +// ============================================================================ + +// Dummy embedding engine for testing +typedef struct { + float embedding[4]; + int dimension; + int compute_count; + char api_key[256]; +} dummy_engine_t; + +static int dummy_compute_calls = 0; +static int dummy_init_calls = 0; + +static void *dummy_init(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { + UNUSED_PARAM(model); + UNUSED_PARAM(xdata); + dummy_init_calls++; + dummy_engine_t *e = (dummy_engine_t *)calloc(1, sizeof(dummy_engine_t)); + if (!e) { snprintf(err_msg, 1024, "alloc failed"); return NULL; } + e->dimension = 4; + e->embedding[0] = 0.1f; + e->embedding[1] = 0.2f; + e->embedding[2] = 0.3f; + e->embedding[3] = 0.4f; + if (api_key) strncpy(e->api_key, api_key, sizeof(e->api_key) - 1); + return e; +} + +static int dummy_compute(void *engine, const char *text, int text_len, void *xdata, dbmem_embedding_result_t *result) { + UNUSED_PARAM(text); + UNUSED_PARAM(text_len); + UNUSED_PARAM(xdata); + dummy_engine_t *e = (dummy_engine_t *)engine; + e->compute_count++; + dummy_compute_calls++; + result->n_tokens = text_len / 4; + result->truncated = false; + result->n_embd = e->dimension; + result->embedding = e->embedding; + return 0; +} + +static int truncated_dummy_compute(void *engine, const char *text, int text_len, void *xdata, dbmem_embedding_result_t *result) { + int rc = dummy_compute(engine, text, text_len, xdata, result); + if (rc != 0) return rc; + result->n_tokens = 3; + result->truncated = true; + return 0; +} + +static void dummy_free(void *engine, void *xdata) { + UNUSED_PARAM(xdata); + free(engine); +} + +TEST(sqlite_memory_add_content_uses_explicit_content_and_context) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *path = "docs/dbmem_explicit_content.md"; + const char *disk_content = "This content came from disk."; + const char *explicit_content = "# Explicit Content\nThis content came from the SQL argument."; + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2, ?3);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, path, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, explicit_content, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, "sync-context", -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int64(stmt, 0), 1); + sqlite3_finalize(stmt); + + char value[256]; + rc = exec_get_text(db, "SELECT value FROM dbmem_content;", value, sizeof(value)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(value, explicit_content); + ASSERT(strstr(value, disk_content) == NULL); + + char stored_path[256]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", stored_path, sizeof(stored_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(stored_path, "docs/dbmem_explicit_content.md"); + + sqlite3_int64 source_count = 1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content_source;", &source_count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(source_count, 0); + + char context[64]; + rc = exec_get_text(db, "SELECT context FROM dbmem_content;", context, sizeof(context)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(context, "sync-context"); + + char indexed_content[256]; + rc = exec_get_text(db, "SELECT group_concat(content, '\n') FROM dbmem_vault_fts;", indexed_content, sizeof(indexed_content)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(indexed_content, "Explicit Content") != NULL); + ASSERT(strstr(indexed_content, "SQL argument") != NULL); + ASSERT(strstr(indexed_content, "disk") == NULL); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_removes_stale_path_when_new_content_is_deduped) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + const char *old_content = "# A\nOld content."; + const char *shared_content = "# Shared\nSame content."; + uint64_t old_hash = dbmem_hash_compute(old_content, strlen(old_content)); + char old_hash_text[DBMEM_HASH_STR_MAXLEN]; + dbmem_hash_to_hex(old_hash, old_hash_text); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/a.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, old_content, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/b.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, shared_content, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/a.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, shared_content, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'docs/a.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char path[64]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path, "docs/b.md"); + + char *sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash_text); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_preserve_duplicate_paths_option_defaults_to_zero) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = -1; + int rc = exec_get_int(db, "SELECT memory_get_option('preserve_duplicate_paths');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('preserve_duplicate_paths');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_get_option('preserve_duplicate_paths');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_stores_empty_content) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/empty.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, "", 0, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'docs/empty.md' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_preserves_duplicate_empty_paths_when_enabled) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('docs/a.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('docs/b.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(DISTINCT hash) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_rekeys_preserved_empty_path_hash) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(DISTINCT hash) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_rekeys_preserved_index_hashes) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nIndexed body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + char old_hash[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = 'untitled-1.md';", old_hash, sizeof(old_hash)); + ASSERT_EQ(rc, SQLITE_OK); + + char *sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + rc = exec_get_int(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char new_hash[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = '1.md';", new_hash, sizeof(new_hash)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strcmp(old_hash, new_hash) != 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", new_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", old_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault_fts WHERE hash = '%q';", new_hash); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result > 0); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nIndexed body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path IN ('1.md', 'untitled-1.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + sqlite3_close(db); +} + +TEST(sqlite_memory_rename_file_rejects_preserved_path_without_saved_content) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('save_content', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('untitled-1.md', '# Heading\nUnsaved body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_rename_file('untitled-1.md', '1.md');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ERROR); + ASSERT(strstr(sqlite3_errmsg(db), "save_content=0") != NULL); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'untitled-1.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = '1.md';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_keeps_same_empty_path_idempotent_when_preserving_duplicates) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('docs/empty-idempotent.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('docs/empty-idempotent.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'docs/empty-idempotent.md' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_requires_preserve_for_directory_marker) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('dirname/', '');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + const char *msg = sqlite3_errmsg(db); + ASSERT(strstr(msg, "preserve_duplicate_paths=1") != NULL); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_creates_directory_marker) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char path[64]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", path, sizeof(path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(path, "dirname/"); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/' AND value = '' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + char json[512]; + rc = exec_get_text(db, "SELECT memory_list_files();", json, sizeof(json)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(json, "{\"root\":\"\",\"children\":[{\"type\":\"directory\",\"name\":\"dirname\",\"path\":\"dirname\",\"children\":[]}]}"); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_directory_marker_is_idempotent) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/';", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_rejects_nonempty_directory_marker) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('dirname/', 'content');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_rejects_file_directory_conflicts) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('dirname', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('dirname/', '');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT memory_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('dirname', '');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_content_preserves_duplicate_nonempty_paths_when_enabled) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + const char *content = "# API\nSame content."; + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/a.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, content, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, "docs/b.md", -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, content, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(DISTINCT hash) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_file_reads_disk_and_stores_context) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *dir = TEST_TMP_DIR "/dbmem_file_context_dir"; + const char *path = TEST_TMP_DIR "/dbmem_file_context_dir/note.md"; + const char *disk_content = "# File Context\nThis content came from disk."; + + remove_test_file(path); + rmdir_p(dir); + mkdir_p(dir); + ASSERT_EQ(create_test_file(path, disk_content), 0); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1, ?2);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, path, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, "file-context", -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int64(stmt, 0), 1); + sqlite3_finalize(stmt); + + char value[256]; + rc = exec_get_text(db, "SELECT value FROM dbmem_content;", value, sizeof(value)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(value, disk_content); + + char stored_path[256]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", stored_path, sizeof(stored_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(stored_path, "dbmem_file_context_dir/note.md") != NULL); + + char source_path[256]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source;", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, path); + + char context[64]; + rc = exec_get_text(db, "SELECT context FROM dbmem_content;", context, sizeof(context)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(context, "file-context"); + + remove_test_file(path); + rmdir_p(dir); + sqlite3_close(db); +} + +TEST(sqlite_memory_add_file_stores_empty_file) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *path = TEST_TMP_DIR "/dbmem_empty_file.md"; + remove_test_file(path); + ASSERT_EQ(create_test_file(path, ""), 0); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, path, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + remove_test_file(path); + sqlite3_close(db); +} + +TEST(sqlite_memory_add_file_preserves_duplicate_empty_paths_when_enabled) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *file1 = TEST_TMP_DIR "/dbmem_empty_file_a.md"; + const char *file2 = TEST_TMP_DIR "/dbmem_empty_file_b.md"; + remove_test_file(file1); + remove_test_file(file2); + ASSERT_EQ(create_test_file(file1, ""), 0); + ASSERT_EQ(create_test_file(file2, ""), 0); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, file1, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, file2, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(DISTINCT hash) FROM dbmem_content;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content_source;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 2); + + remove_test_file(file1); + remove_test_file(file2); + sqlite3_close(db); +} + +TEST(sqlite_memory_add_file_attaches_source_path_to_existing_logical_path) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *dir = TEST_TMP_DIR "/dbmem_attach_source"; + const char *path = TEST_TMP_DIR "/dbmem_attach_source/note.md"; + const char *content = "# Local\nUpdated content."; + + remove_test_file(path); + rmdir_p(dir); + mkdir_p(dir); + ASSERT_EQ(create_test_file(path, content), 0); + + uint64_t hash = dbmem_hash_compute(content, strlen(content)); + char hash_text[DBMEM_HASH_STR_MAXLEN]; + dbmem_hash_to_hex(hash, hash_text); + + char *sql = sqlite3_mprintf( + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES ('%q', 'dbmem_attach_source/note.md', '%q', %d, 'ctx', 0);", + hash_text, content, (int)strlen(content)); + ASSERT(sql != NULL); + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1, 'ctx');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, path, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dbmem_attach_source/note.md';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + char source_path[256]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source;", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, path); + + remove_test_file(path); + rmdir_p(dir); + sqlite3_close(db); +} + +#ifndef _WIN32 +TEST(sqlite_memory_add_file_disambiguates_parent_collisions) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *dir1 = TEST_TMP_DIR "/dbmem_suffix_one/a"; + const char *dir2 = TEST_TMP_DIR "/dbmem_suffix_two/a"; + const char *base1 = TEST_TMP_DIR "/dbmem_suffix_one"; + const char *base2 = TEST_TMP_DIR "/dbmem_suffix_two"; + const char *file1 = TEST_TMP_DIR "/dbmem_suffix_one/a/readme.md"; + const char *file2 = TEST_TMP_DIR "/dbmem_suffix_two/a/readme.md"; + + remove_test_file(file1); + remove_test_file(file2); + rmdir_p(dir1); + rmdir_p(dir2); + rmdir_p(base1); + rmdir_p(base2); + mkdir_p(base1); + mkdir_p(base2); + mkdir_p(dir1); + mkdir_p(dir2); + ASSERT_EQ(create_test_file(file1, "# One\nFirst file."), 0); + ASSERT_EQ(create_test_file(file2, "# Two\nSecond file."), 0); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, file1, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + rc = sqlite3_prepare_v2(db, "SELECT memory_add_file(?1);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_bind_text(stmt, 1, file2, -1, SQLITE_STATIC); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ROW); + sqlite3_finalize(stmt); + + char paths[256]; + rc = exec_get_text(db, "SELECT group_concat(path, '|') FROM dbmem_content ORDER BY path;", paths, sizeof(paths)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(paths, "a/readme.md") != NULL); + ASSERT(strstr(paths, "dbmem_suffix_two/a/readme.md") != NULL); + + char source_path[256]; + rc = exec_get_text(db, + "SELECT s.source_path FROM dbmem_content_source s " + "JOIN dbmem_content c ON c.path = s.path WHERE c.path = 'a/readme.md';", + source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, file1); + + remove_test_file(file1); + remove_test_file(file2); + rmdir_p(dir1); + rmdir_p(dir2); + rmdir_p(base1); + rmdir_p(base2); + sqlite3_close(db); +} +#endif + +TEST(sqlite_memory_add_directory_stores_relative_paths) { + const char *base = TEST_TMP_DIR "/dbmem_relative_scan"; + const char *nested = TEST_TMP_DIR "/dbmem_relative_scan/nested"; + const char *file = TEST_TMP_DIR "/dbmem_relative_scan/nested/note.md"; + + remove_test_file(file); + rmdir_p(nested); + rmdir_p(base); + mkdir_p(base); + mkdir_p(nested); + ASSERT_EQ(create_test_file(file, "# Note\nRelative path."), 0); + + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + char sql[512]; + snprintf(sql, sizeof(sql), "SELECT memory_add_directory('%s');", base); + rc = exec_get_int(db, sql, &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char stored_path[256]; + rc = exec_get_text(db, "SELECT path FROM dbmem_content;", stored_path, sizeof(stored_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(stored_path, "nested/note.md"); + + char source_path[256]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source;", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, file); + + sqlite3_close(db); + remove_test_file(file); + rmdir_p(nested); + rmdir_p(base); +} + +TEST(sqlite_memory_add_directory_preserves_text_entries) { + const char *base = TEST_TMP_DIR "/dbmem_preserve_text_scan"; + const char *file = TEST_TMP_DIR "/dbmem_preserve_text_scan/file.md"; + + remove_test_file(file); + rmdir_p(base); + mkdir_p(base); + ASSERT_EQ(create_test_file(file, "# File\nFilesystem content."), 0); + + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_text('Logical text entry should survive directory sync.', 'test-context');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_add_directory('" TEST_TMP_DIR "/dbmem_preserve_text_scan');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_int64 count = 0; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE context = 'test-context';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + sqlite3_close(db); + remove_test_file(file); + rmdir_p(base); +} + +TEST(sqlite_memory_add_content_rejects_non_text_content) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('docs/readme.md', 123);", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_mdx_preprocessing_applies_only_to_mdx_files) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + const char *mdx_path = TEST_TMP_DIR "/dbmem_mdx_preprocess.mdx"; + const char *md_path = TEST_TMP_DIR "/dbmem_mdx_preprocess.md"; + + remove_test_file(mdx_path); + remove_test_file(md_path); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + ASSERT_EQ(create_test_file(mdx_path, + "import Hidden from './hidden';\n" + "# MDX Visible\n" + "export const hidden = { label: 'Do not index' };\n" + "Shown inner text {hidden.label}.\n"), 0); + + char sql[512]; + snprintf(sql, sizeof(sql), "SELECT memory_add_file('%s');", mdx_path); + rc = exec_get_int(db, sql, &result); + ASSERT_EQ(rc, SQLITE_OK); + + char content[2048]; + rc = exec_get_text(db, "SELECT group_concat(content, '\n') FROM dbmem_vault_fts;", content, sizeof(content)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(content, "MDX Visible") != NULL); + ASSERT(strstr(content, "Shown inner text") != NULL); + ASSERT(strstr(content, "Hidden from") == NULL); + ASSERT(strstr(content, "Do not index") == NULL); + ASSERT(strstr(content, "hidden.label") == NULL); + + rc = exec_get_int(db, "SELECT memory_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + + ASSERT_EQ(create_test_file(md_path, + "import Hidden from './hidden';\n" + "# MD Visible\n" + "export const hidden = { label: 'Do index in markdown' };\n" + "Shown inner text {hidden.label}.\n"), 0); + + snprintf(sql, sizeof(sql), "SELECT memory_add_file('%s');", md_path); + rc = exec_get_int(db, sql, &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_text(db, "SELECT group_concat(content, '\n') FROM dbmem_vault_fts;", content, sizeof(content)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(strstr(content, "MD Visible") != NULL); + ASSERT(strstr(content, "Hidden from") != NULL); + ASSERT(strstr(content, "Do index in markdown") != NULL); + ASSERT(strstr(content, "hidden.label") != NULL); + + remove_test_file(mdx_path); + remove_test_file(md_path); + sqlite3_close(db); +} + +static void *dummy_init_fail(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { + UNUSED_PARAM(model); + UNUSED_PARAM(api_key); + UNUSED_PARAM(xdata); + snprintf(err_msg, 1024, "intentional init failure"); + return NULL; +} + +typedef struct { + int fail_after; + int calls; +} flaky_provider_state_t; + +static void *flaky_init(const char *model, const char *api_key, void *xdata, char err_msg[1024]) { + flaky_provider_state_t *state = (flaky_provider_state_t *)xdata; + if (state) state->calls = 0; + return dummy_init(model, api_key, NULL, err_msg); +} + +static int flaky_compute(void *engine, const char *text, int text_len, void *xdata, dbmem_embedding_result_t *result) { + flaky_provider_state_t *state = (flaky_provider_state_t *)xdata; + if (state) { + state->calls++; + if (state->calls >= state->fail_after) return -1; + } + return dummy_compute(engine, text, text_len, NULL, result); +} + +TEST(sqlite_custom_provider_register) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_close(db); +} + +TEST(sqlite_custom_provider_set_model) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + // set_model should succeed with custom provider + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_close(db); +} + +TEST(sqlite_memory_add_text_requires_model) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT memory_add_text('Hello world, this is a test.');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ERROR); + ASSERT(strstr(sqlite3_errmsg(db), "memory_set_model must be called before adding content") != NULL); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +#ifndef DBMEM_OMIT_REMOTE_ENGINE +TEST(sqlite_saved_remote_model_initializes_lazily_after_apikey) { + const char *path = TEST_TMP_DIR "/dbmem_saved_remote_model.sqlite"; + remove_test_file(path); + + sqlite3 *db = open_test_db_path(path); + ASSERT(db != NULL); + + int rc = sqlite3_exec(db, + "INSERT OR REPLACE INTO dbmem_settings (key, value) VALUES ('provider', 'openai');" + "INSERT OR REPLACE INTO dbmem_settings (key, value) VALUES ('model', 'text-embedding-3-small');", + NULL, NULL, NULL); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_close(db); + + db = open_test_db_path(path); + ASSERT(db != NULL); + + dbmem_context *ctx = get_test_ctx(db); + ASSERT(ctx != NULL); + rc = dbmem_context_ensure_engine(ctx); + ASSERT_EQ(rc, SQLITE_ERROR); + ASSERT(strstr(dbmem_context_errmsg(ctx), "memory_set_apikey must be called") != NULL); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_apikey('test-key');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = dbmem_context_ensure_engine(ctx); + ASSERT_EQ(rc, SQLITE_OK); + + bool is_local = true; + ASSERT(dbmem_context_engine(ctx, &is_local) != NULL); + ASSERT_EQ(is_local, false); + + sqlite3_close(db); + remove_test_file(path); +} +#endif + +#ifndef DBMEM_OMIT_LOCAL_ENGINE +TEST(sqlite_saved_local_model_initializes_lazily) { + const char *path = TEST_TMP_DIR "/dbmem_saved_local_model.sqlite"; + const char *model_path = "models/embeddinggemma-300M-Q8_0.gguf"; + remove_test_file(path); + + if (access(model_path, F_OK) != 0) return; + + sqlite3 *db = open_test_db_path(path); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_model('local', 'models/embeddinggemma-300M-Q8_0.gguf');", &result); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_close(db); + + db = open_test_db_path(path); + ASSERT(db != NULL); + + dbmem_context *ctx = get_test_ctx(db); + ASSERT(ctx != NULL); + + bool is_local = false; + ASSERT(dbmem_context_engine(ctx, &is_local) == NULL); + + rc = exec_get_int(db, "SELECT memory_add_text('Saved local model settings should load lazily.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + + ASSERT(dbmem_context_engine(ctx, &is_local) != NULL); + ASSERT_EQ(is_local, true); + + sqlite3_close(db); + remove_test_file(path); +} +#endif + +TEST(sqlite_saved_custom_model_initializes_lazily_after_register) { + const char *path = TEST_TMP_DIR "/dbmem_saved_custom_model.sqlite"; + remove_test_file(path); + + sqlite3 *db = open_test_db_path(path); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + sqlite3_close(db); + + dummy_init_calls = 0; + dummy_compute_calls = 0; + + db = open_test_db_path(path); + ASSERT(db != NULL); + + dbmem_context *ctx = get_test_ctx(db); + ASSERT(ctx != NULL); + bool is_local = true; + ASSERT(dbmem_context_engine(ctx, &is_local) == NULL); + ASSERT_EQ(dummy_init_calls, 0); + + rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(dummy_init_calls, 0); + + rc = exec_get_int(db, "SELECT memory_add_text('Saved custom provider settings should load lazily.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + ASSERT_EQ(dummy_init_calls, 1); + ASSERT(dummy_compute_calls >= 1); + + ASSERT(dbmem_context_engine(ctx, &is_local) != NULL); + ASSERT_EQ(is_local, false); + + sqlite3_close(db); + remove_test_file(path); +} + +TEST(sqlite_custom_provider_add_text) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // add text should use the custom provider to compute embeddings + rc = exec_get_int(db, "SELECT memory_add_text('Hello world, this is a test.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + + // verify data was stored + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 7); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_cache LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 7); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_cache LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT memory_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_text('Hello world, this is a test.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 7); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_reindex_refreshes_synced_value_changes) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + const char *path = "docs/synced.md"; + const char *old_value = "# Old\nBefore sync."; + const char *new_value = "# New\nAfter sync merge."; + uint64_t old_hash = dbmem_hash_compute(old_value, strlen(old_value)); + uint64_t new_hash = dbmem_hash_compute(new_value, strlen(new_value)); + char old_hash_text[DBMEM_HASH_STR_MAXLEN]; + char new_hash_text[DBMEM_HASH_STR_MAXLEN]; + dbmem_hash_to_hex(old_hash, old_hash_text); + dbmem_hash_to_hex(new_hash, new_hash_text); + + char *sql = sqlite3_mprintf( + "INSERT INTO dbmem_content (hash, path, value, length, context, created_at) " + "VALUES ('%q', '%q', '%q', %d, 'sync', 0);" + "INSERT INTO dbmem_content_source (path, source_path) " + "VALUES ('%q', '/tmp/synced.md');" + "INSERT INTO dbmem_vault (hash, seq, embedding, offset, length, n_tokens, truncated) " + "VALUES ('%q', 0, X'00000000000000000000000000000000', 0, 4, 1, 0);", + old_hash_text, path, new_value, (int)strlen(new_value), + path, old_hash_text); + ASSERT(sql != NULL); + rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_reindex();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char stored_hash[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = 'docs/synced.md';", stored_hash, sizeof(stored_hash)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(stored_hash, new_hash_text); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE hash = (SELECT hash FROM dbmem_content WHERE path = 'docs/synced.md');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(result >= 1); + + sql = sqlite3_mprintf("SELECT COUNT(*) FROM dbmem_vault WHERE hash = '%q';", old_hash_text); + ASSERT(sql != NULL); + rc = exec_get_int(db, sql, &result); + sqlite3_free(sql); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + char source_path[64]; + rc = exec_get_text(db, "SELECT source_path FROM dbmem_content_source WHERE path = 'docs/synced.md';", source_path, sizeof(source_path)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(source_path, "/tmp/synced.md"); + + sqlite3_close(db); +} + +TEST(sqlite_memory_reindex_preserves_directory_markers) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_reindex();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_reindex_preserves_empty_files) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('docs/empty.md', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + char hash_before[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = 'docs/empty.md';", hash_before, sizeof(hash_before)); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_reindex();", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'docs/empty.md' AND value = '' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + char hash_after[DBMEM_HASH_STR_MAXLEN]; + rc = exec_get_text(db, "SELECT hash FROM dbmem_content WHERE path = 'docs/empty.md';", hash_after, sizeof(hash_after)); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_STR_EQ(hash_after, hash_before); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + sqlite3_close(db); +} + +TEST(sqlite_set_model_reindex_preserves_directory_markers) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'model-a');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('preserve_duplicate_paths', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('dirname/', '');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'model-b');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content WHERE path = 'dirname/' AND length = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); sqlite3_close(db); } -TEST(sqlite_custom_provider_set_model) { +TEST(sqlite_custom_provider_skips_whitespace_only_text) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -2417,37 +4906,79 @@ TEST(sqlite_custom_provider_set_model) { int rc = sqlite3_memory_register_provider(db, "dummy", &prov); ASSERT_EQ(rc, SQLITE_OK); - // set_model should succeed with custom provider sqlite3_int64 result = 0; rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); ASSERT_EQ(rc, SQLITE_OK); + + dummy_compute_calls = 0; + rc = exec_get_int(db, "SELECT memory_add_text(' \n\n \n');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + ASSERT_EQ(dummy_compute_calls, 0); + + // no embeddings are computed: the only vault row is the zero-chunk sentinel + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + ASSERT_EQ(rc, SQLITE_OK); ASSERT_EQ(result, 1); + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE length(embedding) = 0 AND n_tokens = 0;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_cache;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + sqlite3_close(db); } -TEST(sqlite_custom_provider_add_text) { +TEST(sqlite_custom_provider_persists_truncated_metadata) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); - dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; - int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + dbmem_provider_t prov = { .init = dummy_init, .compute = truncated_dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "truncdummy", &prov); ASSERT_EQ(rc, SQLITE_OK); sqlite3_int64 result = 0; - rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + rc = exec_get_int(db, "SELECT memory_set_model('truncdummy', 'test-model');", &result); ASSERT_EQ(rc, SQLITE_OK); - // add text should use the custom provider to compute embeddings - rc = exec_get_int(db, "SELECT memory_add_text('Hello world, this is a test.');", &result); + rc = exec_get_int(db, "SELECT memory_add_text('This custom provider reports truncation.');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT(result >= 1); - // verify data was stored - rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &result); + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 3); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_cache LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 3); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_cache LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + rc = exec_get_int(db, "SELECT memory_clear();", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_text('This custom provider reports truncation.');", &result); ASSERT_EQ(rc, SQLITE_OK); ASSERT(result >= 1); + rc = exec_get_int(db, "SELECT n_tokens FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 3); + + rc = exec_get_int(db, "SELECT truncated FROM dbmem_vault LIMIT 1;", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + sqlite3_close(db); } @@ -2610,6 +5141,7 @@ static void tracking_free(void *engine, void *xdata) { free(engine); } +#ifndef DBMEM_OMIT_REMOTE_ENGINE TEST(sqlite_set_model_releases_previous_engine_on_class_switch) { sqlite3 *db = open_test_db(); ASSERT(db != NULL); @@ -2639,6 +5171,280 @@ TEST(sqlite_set_model_releases_previous_engine_on_class_switch) { sqlite3_close(db); ASSERT_EQ(state.free_count, 1); } +#else +TEST(sqlite_set_model_failed_remote_switch_keeps_custom_engine) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_apikey('test-key');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + tracking_free_state_t state = {0}; + dbmem_provider_t prov = { .init = tracking_init, .compute = tracking_compute, .free = tracking_free, .xdata = &state }; + rc = sqlite3_memory_register_provider(db, "tracker", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_model('tracker', 'm1');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(state.free_count, 0); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_set_model('openai', 'text-embedding-3-small');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT_EQ(rc, SQLITE_ERROR); + sqlite3_finalize(stmt); + + ASSERT_EQ(state.free_count, 0); + + sqlite3_close(db); + ASSERT_EQ(state.free_count, 1); +} +#endif + +// ============================================================================ +// Deferred Embeddings Tests +// ============================================================================ + +TEST(sqlite_memory_defer_embeddings_stores_content_without_index) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = -1; + int rc = exec_get_int(db, "SELECT memory_get_option('defer_embeddings');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 0); + + rc = exec_get_int(db, "SELECT memory_set_option('defer_embeddings', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // no model configured: a deferred add must succeed without an embedding engine + rc = exec_get_int(db, "SELECT memory_add_content('docs/deferred.md', '# Title\nDeferred body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_content;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + // embedding pending content requires a configured model + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_embed_pending();", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + const char *msg = sqlite3_errmsg(db); + ASSERT(strstr(msg, "no embedding model") != NULL); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_defer_embeddings_requires_save_content) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + sqlite3_int64 result = 0; + int rc = exec_get_int(db, "SELECT memory_set_option('defer_embeddings', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_set_option('save_content', 0);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT memory_add_content('docs/nosave.md', 'Body text.');", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + rc = sqlite3_step(stmt); + ASSERT(rc == SQLITE_ERROR); + const char *msg = sqlite3_errmsg(db); + ASSERT(strstr(msg, "save_content") != NULL); + sqlite3_finalize(stmt); + + sqlite3_close(db); +} + +TEST(sqlite_memory_embed_pending_embeds_deferred_content_in_batches) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_set_option('defer_embeddings', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('docs/a.md', '# A\nAlpha body content.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('docs/b.md', '# B\nBeta body content.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + rc = exec_get_int(db, "SELECT memory_add_content('docs/c.md', '# C\nGamma body content.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 3); + + rc = exec_get_int(db, "SELECT memory_embed_pending(2);", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT memory_embed_pending();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT(count >= 3); + + sqlite3_int64 fts_count = -1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &fts_count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(fts_count, count); + + rc = exec_get_int(db, + "SELECT COUNT(*) FROM dbmem_content c WHERE NOT EXISTS (SELECT 1 FROM dbmem_vault v WHERE v.hash = c.hash);", + &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT memory_embed_pending();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + sqlite3_close(db); +} + +TEST(sqlite_memory_zero_chunk_content_marks_processed_with_sentinel) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + int calls_before = dummy_compute_calls; + + // whitespace-only content parses to zero chunks: a sentinel vault row marks it processed + rc = exec_get_int(db, "SELECT memory_add_content('docs/blank.md', ' ' || char(10) || char(9) || char(10));", &result); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(result, 1); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE length(embedding) = 0 AND n_tokens = 0;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault_fts;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + ASSERT_EQ(dummy_compute_calls, calls_before); + + // deferred zero-chunk content resolves through memory_embed_pending the same way + rc = exec_get_int(db, "SELECT memory_set_option('defer_embeddings', 1);", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_add_content('docs/blank2.md', char(10) || ' ' || char(10));", &result); + ASSERT_EQ(rc, SQLITE_OK); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT memory_embed_pending();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 1); + + rc = exec_get_int(db, "SELECT memory_pending_count();", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_vault WHERE length(embedding) = 0 AND n_tokens = 0;", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 2); + + ASSERT_EQ(dummy_compute_calls, calls_before); + + sqlite3_close(db); +} + +TEST(sqlite_memory_zero_chunk_first_add_does_not_persist_zero_dimension) { + sqlite3 *db = open_test_db(); + ASSERT(db != NULL); + + dbmem_provider_t prov = { .init = dummy_init, .compute = dummy_compute, .free = dummy_free }; + int rc = sqlite3_memory_register_provider(db, "dummy", &prov); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 result = 0; + rc = exec_get_int(db, "SELECT memory_set_model('dummy', 'test-model');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + // a zero-chunk first add must not latch dimension=0 into dbmem_settings + rc = exec_get_int(db, "SELECT memory_add_content('docs/blank.md', ' ' || char(10) || char(9));", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 count = -1; + rc = exec_get_int(db, "SELECT COUNT(*) FROM dbmem_settings WHERE key = 'dimension';", &count); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(count, 0); + + // the first real embedding persists the provider dimension + rc = exec_get_int(db, "SELECT memory_add_content('docs/real.md', '# Title' || char(10) || 'Real body text.');", &result); + ASSERT_EQ(rc, SQLITE_OK); + + sqlite3_int64 dimension = -1; + rc = exec_get_int(db, "SELECT value FROM dbmem_settings WHERE key = 'dimension';", &dimension); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(dimension, 4); + + sqlite3_close(db); +} + +#ifndef DBMEM_OMIT_LOCAL_ENGINE +TEST(sqlite_local_logger_ignores_stale_user_data) { + dbmem_logger(GGML_LOG_LEVEL_WARN, "ignored warning", (void *)1); +} +#endif #endif // TEST_SQLITE_EXTENSION @@ -2666,6 +5472,10 @@ int main(int argc, char *argv[]) { RUN_TEST(dbmem_parse_strips_blockquotes); RUN_TEST(dbmem_parse_strips_html); RUN_TEST(dbmem_parse_preserves_html); + RUN_TEST(dbmem_parse_mdx_strips_esm_and_expressions); + RUN_TEST(dbmem_parse_mdx_preserves_fenced_code); + RUN_TEST(dbmem_parse_mdx_keeps_import_export_prose_and_indented_code); + RUN_TEST(dbmem_parse_mdx_real_docs_file); RUN_TEST(dbmem_parse_inline_code); RUN_TEST(dbmem_parse_image); RUN_TEST(dbmem_parse_strikethrough); @@ -2731,10 +5541,44 @@ int main(int argc, char *argv[]) { #ifdef TEST_SQLITE_EXTENSION printf("\nSQLite extension tests:\n"); RUN_TEST(sqlite_memory_version); + RUN_TEST(sqlite_memory_is_enabled); + RUN_TEST(sqlite_memory_is_enabled_missing_table); + RUN_TEST(sqlite_memory_is_enabled_ignores_schema_version); RUN_TEST(sqlite_memory_clear_empty); RUN_TEST(sqlite_memory_delete_nonexistent); RUN_TEST(sqlite_memory_delete_context_nonexistent); + RUN_TEST(sqlite_memory_delete_file_direct); + RUN_TEST(sqlite_memory_delete_file_missing); + RUN_TEST(sqlite_memory_delete_file_matches_source_path); + RUN_TEST(sqlite_memory_delete_file_rejects_ambiguous_path); + RUN_TEST(sqlite_memory_delete_file_invalid_path); + RUN_TEST(sqlite_memory_delete_file_removes_directory_marker_only); + RUN_TEST(sqlite_memory_rename_file_direct); + RUN_TEST(sqlite_memory_rename_file_directory_marker); + RUN_TEST(sqlite_memory_rename_file_rejects_marker_file_conversion); + RUN_TEST(sqlite_memory_rename_file_matches_source_path); + RUN_TEST(sqlite_memory_rename_file_missing); + RUN_TEST(sqlite_memory_rename_file_duplicate_path); + RUN_TEST(sqlite_memory_rename_file_rejects_ambiguous_path); + RUN_TEST(sqlite_memory_list_files_empty); + RUN_TEST(sqlite_memory_list_files_strips_common_full_path); + RUN_TEST(sqlite_memory_list_files_keeps_relative_paths); + RUN_TEST(sqlite_memory_list_files_strips_single_full_path_directory); + RUN_TEST(sqlite_memory_list_files_normalizes_windows_separators); + RUN_TEST(sqlite_memory_list_files_does_not_strip_mixed_path_types); + RUN_TEST(sqlite_memory_list_files_omits_empty_paths); + RUN_TEST(sqlite_memory_list_files_escapes_json_strings); + RUN_TEST(sqlite_memory_list_files_includes_empty_directory_marker); + RUN_TEST(sqlite_memory_list_files_merges_directory_marker_with_children); + RUN_TEST(sqlite_memory_list_files_reports_indexed_flag); + RUN_TEST(sqlite_memory_materialize_files_creates_directories_and_files); + RUN_TEST(sqlite_memory_materialize_files_creates_directory_markers); + RUN_TEST(sqlite_memory_materialize_files_accepts_existing_same_content); + RUN_TEST(sqlite_memory_materialize_files_rejects_parent_segments); + RUN_TEST(sqlite_memory_materialize_files_rejects_null_content); RUN_TEST(sqlite_schema_has_timestamps); + RUN_TEST(sqlite_schema_migrates_embedding_metadata); + RUN_TEST(sqlite_schema_migrates_source_path_to_local_table); RUN_TEST(sqlite_direct_insert_with_timestamp); RUN_TEST(sqlite_memory_delete_direct); RUN_TEST(sqlite_memory_delete_context_direct); @@ -2745,6 +5589,7 @@ int main(int argc, char *argv[]) { RUN_TEST(sqlite_sync_directory_removes_all_deleted); RUN_TEST(sqlite_sync_directory_skips_unchanged); RUN_TEST(sqlite_sync_directory_ignores_sibling_prefixes); + RUN_TEST(sqlite_sync_directory_keeps_logical_rows_without_source_path); printf("\nSQLite extension advanced tests:\n"); RUN_TEST(sqlite_memory_delete_with_vault_data); @@ -2774,12 +5619,67 @@ int main(int argc, char *argv[]) { printf("\nCustom provider tests:\n"); RUN_TEST(sqlite_custom_provider_register); RUN_TEST(sqlite_custom_provider_set_model); + RUN_TEST(sqlite_memory_add_text_requires_model); +#ifndef DBMEM_OMIT_REMOTE_ENGINE + RUN_TEST(sqlite_saved_remote_model_initializes_lazily_after_apikey); +#endif +#ifndef DBMEM_OMIT_LOCAL_ENGINE + RUN_TEST(sqlite_saved_local_model_initializes_lazily); +#endif + RUN_TEST(sqlite_saved_custom_model_initializes_lazily_after_register); RUN_TEST(sqlite_custom_provider_add_text); + RUN_TEST(sqlite_memory_reindex_refreshes_synced_value_changes); + RUN_TEST(sqlite_memory_reindex_preserves_directory_markers); + RUN_TEST(sqlite_memory_reindex_preserves_empty_files); + RUN_TEST(sqlite_set_model_reindex_preserves_directory_markers); + RUN_TEST(sqlite_custom_provider_skips_whitespace_only_text); + RUN_TEST(sqlite_custom_provider_persists_truncated_metadata); + RUN_TEST(sqlite_memory_add_content_uses_explicit_content_and_context); + RUN_TEST(sqlite_memory_add_content_removes_stale_path_when_new_content_is_deduped); + RUN_TEST(sqlite_memory_preserve_duplicate_paths_option_defaults_to_zero); + RUN_TEST(sqlite_memory_add_content_stores_empty_content); + RUN_TEST(sqlite_memory_add_content_preserves_duplicate_empty_paths_when_enabled); + RUN_TEST(sqlite_memory_rename_file_rekeys_preserved_empty_path_hash); + RUN_TEST(sqlite_memory_rename_file_rekeys_preserved_index_hashes); + RUN_TEST(sqlite_memory_rename_file_rejects_preserved_path_without_saved_content); + RUN_TEST(sqlite_memory_add_content_keeps_same_empty_path_idempotent_when_preserving_duplicates); + RUN_TEST(sqlite_memory_add_content_requires_preserve_for_directory_marker); + RUN_TEST(sqlite_memory_add_content_creates_directory_marker); + RUN_TEST(sqlite_memory_add_content_directory_marker_is_idempotent); + RUN_TEST(sqlite_memory_add_content_rejects_nonempty_directory_marker); + RUN_TEST(sqlite_memory_add_content_rejects_file_directory_conflicts); + RUN_TEST(sqlite_memory_add_content_preserves_duplicate_nonempty_paths_when_enabled); + RUN_TEST(sqlite_memory_add_file_reads_disk_and_stores_context); + RUN_TEST(sqlite_memory_add_file_stores_empty_file); + RUN_TEST(sqlite_memory_add_file_preserves_duplicate_empty_paths_when_enabled); + RUN_TEST(sqlite_memory_add_file_attaches_source_path_to_existing_logical_path); +#ifndef _WIN32 + RUN_TEST(sqlite_memory_add_file_disambiguates_parent_collisions); +#endif + RUN_TEST(sqlite_memory_add_directory_stores_relative_paths); + RUN_TEST(sqlite_memory_add_directory_preserves_text_entries); + RUN_TEST(sqlite_memory_add_content_rejects_non_text_content); + RUN_TEST(sqlite_mdx_preprocessing_applies_only_to_mdx_files); RUN_TEST(sqlite_custom_provider_null_callbacks); RUN_TEST(sqlite_custom_provider_init_error); RUN_TEST(sqlite_custom_provider_apikey_passed); RUN_TEST(sqlite_set_model_failed_reindex_preserves_existing_rows); + + printf("\nDeferred embeddings tests:\n"); + RUN_TEST(sqlite_memory_defer_embeddings_stores_content_without_index); + RUN_TEST(sqlite_memory_defer_embeddings_requires_save_content); + RUN_TEST(sqlite_memory_embed_pending_embeds_deferred_content_in_batches); + RUN_TEST(sqlite_memory_zero_chunk_content_marks_processed_with_sentinel); + RUN_TEST(sqlite_memory_zero_chunk_first_add_does_not_persist_zero_dimension); + +#ifndef DBMEM_OMIT_REMOTE_ENGINE RUN_TEST(sqlite_set_model_releases_previous_engine_on_class_switch); +#else + RUN_TEST(sqlite_set_model_failed_remote_switch_keeps_custom_engine); +#endif +#ifndef DBMEM_OMIT_LOCAL_ENGINE + RUN_TEST(sqlite_local_logger_ignores_stale_user_data); +#endif #endif printf("\n=== Results ===\n");