mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
* MAESTRO: fix ChromaDB core issues — Python pinning, Windows paths, disable toggle, metadata sanitization, transport errors - Add --python version pinning to uvx args in both local and remote mode (fixes #1196, #1206, #1208) - Convert backslash paths to forward slashes for --data-dir on Windows (fixes #1199) - Add CLAUDE_MEM_CHROMA_ENABLED setting for SQLite-only fallback mode (fixes #707) - Sanitize metadata in addDocuments() to filter null/undefined/empty values (fixes #1183, #1188) - Wrap callTool() in try/catch for transport errors with auto-reconnect (fixes #1162) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix data integrity — content-hash deduplication, project name collision, empty project guard, stuck isProcessing - Add SHA-256 content-hash deduplication to observations INSERT (store.ts, transactions.ts, SessionStore.ts) - Add content_hash column via migration 22 with backfill and index - Fix project name collision: getCurrentProjectName() now returns parent/basename - Guard against empty project string with cwd-derived fallback - Fix stuck isProcessing: hasAnyPendingWork() resets processing messages older than 5 minutes - Add 12 new tests covering all four fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix hook lifecycle — stderr suppression, output isolation, conversation pollution prevention - Suppress process.stderr.write in hookCommand() to prevent Claude Code showing diagnostic output as error UI (#1181). Restores stderr in finally block for worker-continues case. - Convert console.error() to logger.warn()/error() in hook-command.ts and handlers/index.ts so all diagnostics route to log file instead of stderr. - Verified all 7 handlers return suppressOutput: true (prevents conversation pollution #598, #784). - Verified session-complete is a recognized event type (fixes #984). - Verified unknown event types return no-op handler with exit 0 (graceful degradation). - Added 10 new tests in tests/hook-lifecycle.test.ts covering event dispatch, adapter defaults, stderr suppression, and standard response constants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix worker lifecycle — restart loop coordination, stale transport retry, ENOENT shutdown race - Add PID file mtime guard to prevent concurrent restart storms (#1145): isPidFileRecent() + touchPidFile() coordinate across sessions - Add transparent retry in ChromaMcpManager.callTool() on transport error — reconnects and retries once instead of failing (#1131) - Wrap getInstalledPluginVersion() with ENOENT/EBUSY handling (#1042) - Verified ChromaMcpManager.stop() already called on all shutdown paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix Windows platform support — uvx.cmd spawn, PowerShell $_ elimination, windowsHide, FTS5 fallback - Route uvx spawn through cmd.exe /c on Windows since MCP SDK lacks shell:true (#1190, #1192, #1199) - Replace all PowerShell Where-Object {$_} pipelines with WQL -Filter server-side filtering (#1024, #1062) - Add windowsHide: true to all exec/spawn calls missing it to prevent console popups (#1048) - Add FTS5 runtime probe with graceful fallback when unavailable on Windows (#791) - Guard FTS5 table creation in migrations, SessionSearch, and SessionStore with try/catch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix skills/ distribution — build-time verification and regression tests (#1187) Add post-build verification in build-hooks.js that fails if critical distribution files (skills, hooks, plugin manifest) are missing. Add 10 regression tests covering skill file presence, YAML frontmatter, hooks.json integrity, and package.json files field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix MigrationRunner schema initialization (#979) — version conflict between parallel migration systems Root cause: old DatabaseManager migrations 1-7 shared schema_versions table with MigrationRunner's 4-22, causing version number collisions (5=drop tables vs add column, 6=FTS5 vs prompt tracking, 7=discovery_tokens vs remove UNIQUE). initializeSchema() was gated behind maxApplied===0, so core tables were never created when old versions were present. Fixes: - initializeSchema() always creates core tables via CREATE TABLE IF NOT EXISTS - Migrations 5-7 check actual DB state (columns/constraints) not just version tracking - Crash-safe temp table rebuilds (DROP IF EXISTS _new before CREATE) - Added missing migration 21 (ON UPDATE CASCADE) to MigrationRunner - Added ON UPDATE CASCADE to FK definitions in initializeSchema() - All changes applied to both runner.ts and SessionStore.ts Tests: 13 new tests in migration-runner.test.ts covering fresh DB, idempotency, version conflicts, crash recovery, FK constraints, and data integrity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix 21 test failures — stale mocks, outdated assertions, missing OpenClaw guards Server tests (12): Added missing workerPath and getAiStatus to ServerOptions mocks after interface expansion. ChromaSync tests (3): Updated to verify transport cleanup in ChromaMcpManager after architecture refactor. OpenClaw (2): Added memory_ tool skipping and response truncation to prevent recursive loops and oversized payloads. MarkdownFormatter (2): Updated assertions to match current output. SettingsDefaultsManager (1): Used correct default key for getBool test. Logger standards (1): Excluded CLI transcript command from background service check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix Codex CLI compatibility (#744) — session_id fallbacks, unknown platform tolerance, undefined guard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix Cursor IDE integration (#838, #1049) — adapter field fallbacks, tolerant session-init validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix /api/logs OOM (#1203) — tail-read replaces full-file readFileSync Replace readFileSync (loads entire file into memory) with readLastLines() that reads only from the end of the file in expanding chunks (64KB → 10MB cap). Prevents OOM on large log files while preserving the same API response shape. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix Settings CORS error (#1029) — explicit methods and allowedHeaders in CORS config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: add session custom_title for agent attribution (#1213) — migration 23, endpoint + store support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: prevent CLAUDE.md/AGENTS.md writes inside .git/ directories (#1165) Add .git path guard to all 4 write sites to prevent ref corruption when paths resolve inside .git internals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix plugin disabled state not respected (#781) — early exit check in all hook entry points Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix UserPromptSubmit context re-injection on every turn (#1079) — contextInjected session flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * MAESTRO: fix stale AbortController queue stall (#1099) — lastGeneratorActivity tracking + 30s timeout Three-layer fix: 1. Added lastGeneratorActivity timestamp to ActiveSession, updated by processAgentResponse (all agents), getMessageIterator (queue yields), and startGeneratorWithProvider (generator launch) 2. Added stale generator detection in ensureGeneratorRunning — if no activity for >30s, aborts stale controller, resets state, restarts 3. Added AbortSignal.timeout(30000) in deleteSession to prevent indefinite hang when awaiting a stuck generator promise Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import { TableNameRow } from '../../types/database.js';
|
|
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
|
import { logger } from '../../utils/logger.js';
|
|
import { isDirectChild } from '../../shared/path-utils.js';
|
|
import {
|
|
ObservationSearchResult,
|
|
SessionSummarySearchResult,
|
|
UserPromptSearchResult,
|
|
SearchOptions,
|
|
SearchFilters,
|
|
DateRange,
|
|
ObservationRow,
|
|
UserPromptRow
|
|
} from './types.js';
|
|
|
|
/**
|
|
* Search interface for session-based memory
|
|
* Provides filter-only structured queries for sessions, observations, and user prompts
|
|
* Vector search is handled by ChromaDB - this class only supports filtering without query text
|
|
*/
|
|
export class SessionSearch {
|
|
private db: Database;
|
|
|
|
constructor(dbPath?: string) {
|
|
if (!dbPath) {
|
|
ensureDir(DATA_DIR);
|
|
dbPath = DB_PATH;
|
|
}
|
|
this.db = new Database(dbPath);
|
|
this.db.run('PRAGMA journal_mode = WAL');
|
|
|
|
// Ensure FTS tables exist
|
|
this.ensureFTSTables();
|
|
}
|
|
|
|
/**
|
|
* Ensure FTS5 tables exist (backward compatibility only - no longer used for search)
|
|
*
|
|
* FTS5 tables are maintained for backward compatibility but not used for search.
|
|
* Vector search (Chroma) is now the primary search mechanism.
|
|
*
|
|
* Retention Rationale:
|
|
* - Prevents breaking existing installations with FTS5 tables
|
|
* - Allows graceful migration path for users
|
|
* - Tables maintained but search paths removed
|
|
* - Triggers still fire to keep tables synchronized
|
|
*
|
|
* FTS5 may be unavailable on some platforms (e.g., Bun on Windows #791).
|
|
* When unavailable, we skip FTS table creation — search falls back to
|
|
* ChromaDB (vector) and LIKE queries (structured filters) which are unaffected.
|
|
*
|
|
* TODO: Remove FTS5 infrastructure in future major version (v7.0.0)
|
|
*/
|
|
private ensureFTSTables(): void {
|
|
// Check if FTS tables already exist
|
|
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts'").all() as TableNameRow[];
|
|
const hasFTS = tables.some(t => t.name === 'observations_fts' || t.name === 'session_summaries_fts');
|
|
|
|
if (hasFTS) {
|
|
// Already migrated
|
|
return;
|
|
}
|
|
|
|
// Runtime check: verify FTS5 is available before attempting to create tables.
|
|
// bun:sqlite on Windows may not include the FTS5 extension (#791).
|
|
if (!this.isFts5Available()) {
|
|
logger.warn('DB', 'FTS5 not available on this platform — skipping FTS table creation (search uses ChromaDB)');
|
|
return;
|
|
}
|
|
|
|
logger.info('DB', 'Creating FTS5 tables');
|
|
|
|
try {
|
|
// Create observations_fts virtual table
|
|
this.db.run(`
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
|
title,
|
|
subtitle,
|
|
narrative,
|
|
text,
|
|
facts,
|
|
concepts,
|
|
content='observations',
|
|
content_rowid='id'
|
|
);
|
|
`);
|
|
|
|
// Populate with existing data
|
|
this.db.run(`
|
|
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
|
SELECT id, title, subtitle, narrative, text, facts, concepts
|
|
FROM observations;
|
|
`);
|
|
|
|
// Create triggers for observations
|
|
this.db.run(`
|
|
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
|
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
|
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
|
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
|
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
|
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
|
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
|
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
|
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
|
END;
|
|
`);
|
|
|
|
// Create session_summaries_fts virtual table
|
|
this.db.run(`
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
|
|
request,
|
|
investigated,
|
|
learned,
|
|
completed,
|
|
next_steps,
|
|
notes,
|
|
content='session_summaries',
|
|
content_rowid='id'
|
|
);
|
|
`);
|
|
|
|
// Populate with existing data
|
|
this.db.run(`
|
|
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
|
SELECT id, request, investigated, learned, completed, next_steps, notes
|
|
FROM session_summaries;
|
|
`);
|
|
|
|
// Create triggers for session_summaries
|
|
this.db.run(`
|
|
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
|
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
|
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
|
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
|
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
|
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
|
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
|
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
|
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
|
END;
|
|
`);
|
|
|
|
logger.info('DB', 'FTS5 tables created successfully');
|
|
} catch (error) {
|
|
// FTS5 creation failed at runtime despite probe succeeding — degrade gracefully
|
|
logger.warn('DB', 'FTS5 table creation failed — search will use ChromaDB and LIKE queries', {}, error as Error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Probe whether the FTS5 extension is available in the current SQLite build.
|
|
* Creates and immediately drops a temporary FTS5 table.
|
|
*/
|
|
private isFts5Available(): boolean {
|
|
try {
|
|
this.db.run('CREATE VIRTUAL TABLE _fts5_probe USING fts5(test_column)');
|
|
this.db.run('DROP TABLE _fts5_probe');
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Build WHERE clause for structured filters
|
|
*/
|
|
private buildFilterClause(
|
|
filters: SearchFilters,
|
|
params: any[],
|
|
tableAlias: string = 'o'
|
|
): string {
|
|
const conditions: string[] = [];
|
|
|
|
// Project filter
|
|
if (filters.project) {
|
|
conditions.push(`${tableAlias}.project = ?`);
|
|
params.push(filters.project);
|
|
}
|
|
|
|
// Type filter (for observations only)
|
|
if (filters.type) {
|
|
if (Array.isArray(filters.type)) {
|
|
const placeholders = filters.type.map(() => '?').join(',');
|
|
conditions.push(`${tableAlias}.type IN (${placeholders})`);
|
|
params.push(...filters.type);
|
|
} else {
|
|
conditions.push(`${tableAlias}.type = ?`);
|
|
params.push(filters.type);
|
|
}
|
|
}
|
|
|
|
// Date range filter
|
|
if (filters.dateRange) {
|
|
const { start, end } = filters.dateRange;
|
|
if (start) {
|
|
const startEpoch = typeof start === 'number' ? start : new Date(start).getTime();
|
|
conditions.push(`${tableAlias}.created_at_epoch >= ?`);
|
|
params.push(startEpoch);
|
|
}
|
|
if (end) {
|
|
const endEpoch = typeof end === 'number' ? end : new Date(end).getTime();
|
|
conditions.push(`${tableAlias}.created_at_epoch <= ?`);
|
|
params.push(endEpoch);
|
|
}
|
|
}
|
|
|
|
// Concepts filter (JSON array search)
|
|
if (filters.concepts) {
|
|
const concepts = Array.isArray(filters.concepts) ? filters.concepts : [filters.concepts];
|
|
const conceptConditions = concepts.map(() => {
|
|
return `EXISTS (SELECT 1 FROM json_each(${tableAlias}.concepts) WHERE value = ?)`;
|
|
});
|
|
if (conceptConditions.length > 0) {
|
|
conditions.push(`(${conceptConditions.join(' OR ')})`);
|
|
params.push(...concepts);
|
|
}
|
|
}
|
|
|
|
// Files filter (JSON array search)
|
|
if (filters.files) {
|
|
const files = Array.isArray(filters.files) ? filters.files : [filters.files];
|
|
const fileConditions = files.map(() => {
|
|
return `(
|
|
EXISTS (SELECT 1 FROM json_each(${tableAlias}.files_read) WHERE value LIKE ?)
|
|
OR EXISTS (SELECT 1 FROM json_each(${tableAlias}.files_modified) WHERE value LIKE ?)
|
|
)`;
|
|
});
|
|
if (fileConditions.length > 0) {
|
|
conditions.push(`(${fileConditions.join(' OR ')})`);
|
|
files.forEach(file => {
|
|
params.push(`%${file}%`, `%${file}%`);
|
|
});
|
|
}
|
|
}
|
|
|
|
return conditions.length > 0 ? conditions.join(' AND ') : '';
|
|
}
|
|
|
|
/**
|
|
* Build ORDER BY clause
|
|
*/
|
|
private buildOrderClause(orderBy: SearchOptions['orderBy'] = 'relevance', hasFTS: boolean = true, ftsTable: string = 'observations_fts'): string {
|
|
switch (orderBy) {
|
|
case 'relevance':
|
|
return hasFTS ? `ORDER BY ${ftsTable}.rank ASC` : 'ORDER BY o.created_at_epoch DESC';
|
|
case 'date_desc':
|
|
return 'ORDER BY o.created_at_epoch DESC';
|
|
case 'date_asc':
|
|
return 'ORDER BY o.created_at_epoch ASC';
|
|
default:
|
|
return 'ORDER BY o.created_at_epoch DESC';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search observations using filter-only direct SQLite query.
|
|
* Vector search is handled by ChromaDB - this only supports filtering without query text.
|
|
*/
|
|
searchObservations(query: string | undefined, options: SearchOptions = {}): ObservationSearchResult[] {
|
|
const params: any[] = [];
|
|
const { limit = 50, offset = 0, orderBy = 'relevance', ...filters } = options;
|
|
|
|
// FILTER-ONLY PATH: When no query text, query table directly
|
|
// This enables date filtering which Chroma cannot do (requires direct SQLite access)
|
|
if (!query) {
|
|
const filterClause = this.buildFilterClause(filters, params, 'o');
|
|
if (!filterClause) {
|
|
throw new Error('Either query or filters required for search');
|
|
}
|
|
|
|
const orderClause = this.buildOrderClause(orderBy, false);
|
|
|
|
const sql = `
|
|
SELECT o.*, o.discovery_tokens
|
|
FROM observations o
|
|
WHERE ${filterClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
|
}
|
|
|
|
// Vector search with query text should be handled by ChromaDB
|
|
// This method only supports filter-only queries (query=undefined)
|
|
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Search session summaries using filter-only direct SQLite query.
|
|
* Vector search is handled by ChromaDB - this only supports filtering without query text.
|
|
*/
|
|
searchSessions(query: string | undefined, options: SearchOptions = {}): SessionSummarySearchResult[] {
|
|
const params: any[] = [];
|
|
const { limit = 50, offset = 0, orderBy = 'relevance', ...filters } = options;
|
|
|
|
// FILTER-ONLY PATH: When no query text, query session_summaries table directly
|
|
if (!query) {
|
|
const filterOptions = { ...filters };
|
|
delete filterOptions.type;
|
|
const filterClause = this.buildFilterClause(filterOptions, params, 's');
|
|
if (!filterClause) {
|
|
throw new Error('Either query or filters required for search');
|
|
}
|
|
|
|
const orderClause = orderBy === 'date_asc'
|
|
? 'ORDER BY s.created_at_epoch ASC'
|
|
: 'ORDER BY s.created_at_epoch DESC';
|
|
|
|
const sql = `
|
|
SELECT s.*, s.discovery_tokens
|
|
FROM session_summaries s
|
|
WHERE ${filterClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
return this.db.prepare(sql).all(...params) as SessionSummarySearchResult[];
|
|
}
|
|
|
|
// Vector search with query text should be handled by ChromaDB
|
|
// This method only supports filter-only queries (query=undefined)
|
|
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Find observations by concept tag
|
|
*/
|
|
findByConcept(concept: string, options: SearchOptions = {}): ObservationSearchResult[] {
|
|
const params: any[] = [];
|
|
const { limit = 50, offset = 0, orderBy = 'date_desc', ...filters } = options;
|
|
|
|
// Add concept to filters
|
|
const conceptFilters = { ...filters, concepts: concept };
|
|
const filterClause = this.buildFilterClause(conceptFilters, params, 'o');
|
|
const orderClause = this.buildOrderClause(orderBy, false);
|
|
|
|
const sql = `
|
|
SELECT o.*, o.discovery_tokens
|
|
FROM observations o
|
|
WHERE ${filterClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
|
|
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
|
}
|
|
|
|
/**
|
|
* Check if an observation has any files that are direct children of the folder
|
|
*/
|
|
private hasDirectChildFile(obs: ObservationSearchResult, folderPath: string): boolean {
|
|
const checkFiles = (filesJson: string | null): boolean => {
|
|
if (!filesJson) return false;
|
|
try {
|
|
const files = JSON.parse(filesJson);
|
|
if (Array.isArray(files)) {
|
|
return files.some(f => isDirectChild(f, folderPath));
|
|
}
|
|
} catch {}
|
|
return false;
|
|
};
|
|
|
|
return checkFiles(obs.files_modified) || checkFiles(obs.files_read);
|
|
}
|
|
|
|
/**
|
|
* Check if a session has any files that are direct children of the folder
|
|
*/
|
|
private hasDirectChildFileSession(session: SessionSummarySearchResult, folderPath: string): boolean {
|
|
const checkFiles = (filesJson: string | null): boolean => {
|
|
if (!filesJson) return false;
|
|
try {
|
|
const files = JSON.parse(filesJson);
|
|
if (Array.isArray(files)) {
|
|
return files.some(f => isDirectChild(f, folderPath));
|
|
}
|
|
} catch {}
|
|
return false;
|
|
};
|
|
|
|
return checkFiles(session.files_read) || checkFiles(session.files_edited);
|
|
}
|
|
|
|
/**
|
|
* Find observations and summaries by file path
|
|
* When isFolder=true, only returns results with files directly in the folder (not subfolders)
|
|
*/
|
|
findByFile(filePath: string, options: SearchOptions = {}): {
|
|
observations: ObservationSearchResult[];
|
|
sessions: SessionSummarySearchResult[];
|
|
} {
|
|
const params: any[] = [];
|
|
const { limit = 50, offset = 0, orderBy = 'date_desc', isFolder = false, ...filters } = options;
|
|
|
|
// Query more results if we're filtering to direct children
|
|
const queryLimit = isFolder ? limit * 3 : limit;
|
|
|
|
// Add file to filters
|
|
const fileFilters = { ...filters, files: filePath };
|
|
const filterClause = this.buildFilterClause(fileFilters, params, 'o');
|
|
const orderClause = this.buildOrderClause(orderBy, false);
|
|
|
|
const observationsSql = `
|
|
SELECT o.*, o.discovery_tokens
|
|
FROM observations o
|
|
WHERE ${filterClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(queryLimit, offset);
|
|
|
|
let observations = this.db.prepare(observationsSql).all(...params) as ObservationSearchResult[];
|
|
|
|
// Post-filter to direct children if isFolder mode
|
|
if (isFolder) {
|
|
observations = observations.filter(obs => this.hasDirectChildFile(obs, filePath)).slice(0, limit);
|
|
}
|
|
|
|
// For session summaries, search files_read and files_edited
|
|
const sessionParams: any[] = [];
|
|
const sessionFilters = { ...filters };
|
|
delete sessionFilters.type; // Remove type filter for sessions
|
|
|
|
const baseConditions: string[] = [];
|
|
if (sessionFilters.project) {
|
|
baseConditions.push('s.project = ?');
|
|
sessionParams.push(sessionFilters.project);
|
|
}
|
|
|
|
if (sessionFilters.dateRange) {
|
|
const { start, end } = sessionFilters.dateRange;
|
|
if (start) {
|
|
const startEpoch = typeof start === 'number' ? start : new Date(start).getTime();
|
|
baseConditions.push('s.created_at_epoch >= ?');
|
|
sessionParams.push(startEpoch);
|
|
}
|
|
if (end) {
|
|
const endEpoch = typeof end === 'number' ? end : new Date(end).getTime();
|
|
baseConditions.push('s.created_at_epoch <= ?');
|
|
sessionParams.push(endEpoch);
|
|
}
|
|
}
|
|
|
|
// File condition
|
|
baseConditions.push(`(
|
|
EXISTS (SELECT 1 FROM json_each(s.files_read) WHERE value LIKE ?)
|
|
OR EXISTS (SELECT 1 FROM json_each(s.files_edited) WHERE value LIKE ?)
|
|
)`);
|
|
sessionParams.push(`%${filePath}%`, `%${filePath}%`);
|
|
|
|
const sessionsSql = `
|
|
SELECT s.*, s.discovery_tokens
|
|
FROM session_summaries s
|
|
WHERE ${baseConditions.join(' AND ')}
|
|
ORDER BY s.created_at_epoch DESC
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
sessionParams.push(queryLimit, offset);
|
|
|
|
let sessions = this.db.prepare(sessionsSql).all(...sessionParams) as SessionSummarySearchResult[];
|
|
|
|
// Post-filter to direct children if isFolder mode
|
|
if (isFolder) {
|
|
sessions = sessions.filter(s => this.hasDirectChildFileSession(s, filePath)).slice(0, limit);
|
|
}
|
|
|
|
return { observations, sessions };
|
|
}
|
|
|
|
/**
|
|
* Find observations by type
|
|
*/
|
|
findByType(
|
|
type: ObservationRow['type'] | ObservationRow['type'][],
|
|
options: SearchOptions = {}
|
|
): ObservationSearchResult[] {
|
|
const params: any[] = [];
|
|
const { limit = 50, offset = 0, orderBy = 'date_desc', ...filters } = options;
|
|
|
|
// Add type to filters
|
|
const typeFilters = { ...filters, type };
|
|
const filterClause = this.buildFilterClause(typeFilters, params, 'o');
|
|
const orderClause = this.buildOrderClause(orderBy, false);
|
|
|
|
const sql = `
|
|
SELECT o.*, o.discovery_tokens
|
|
FROM observations o
|
|
WHERE ${filterClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
|
|
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
|
}
|
|
|
|
/**
|
|
* Search user prompts using filter-only direct SQLite query.
|
|
* Vector search is handled by ChromaDB - this only supports filtering without query text.
|
|
*/
|
|
searchUserPrompts(query: string | undefined, options: SearchOptions = {}): UserPromptSearchResult[] {
|
|
const params: any[] = [];
|
|
const { limit = 20, offset = 0, orderBy = 'relevance', ...filters } = options;
|
|
|
|
// Build filter conditions (join with sdk_sessions for project filtering)
|
|
const baseConditions: string[] = [];
|
|
if (filters.project) {
|
|
baseConditions.push('s.project = ?');
|
|
params.push(filters.project);
|
|
}
|
|
|
|
if (filters.dateRange) {
|
|
const { start, end } = filters.dateRange;
|
|
if (start) {
|
|
const startEpoch = typeof start === 'number' ? start : new Date(start).getTime();
|
|
baseConditions.push('up.created_at_epoch >= ?');
|
|
params.push(startEpoch);
|
|
}
|
|
if (end) {
|
|
const endEpoch = typeof end === 'number' ? end : new Date(end).getTime();
|
|
baseConditions.push('up.created_at_epoch <= ?');
|
|
params.push(endEpoch);
|
|
}
|
|
}
|
|
|
|
// FILTER-ONLY PATH: When no query text, query user_prompts table directly
|
|
if (!query) {
|
|
if (baseConditions.length === 0) {
|
|
throw new Error('Either query or filters required for search');
|
|
}
|
|
|
|
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
|
|
const orderClause = orderBy === 'date_asc'
|
|
? 'ORDER BY up.created_at_epoch ASC'
|
|
: 'ORDER BY up.created_at_epoch DESC';
|
|
|
|
const sql = `
|
|
SELECT up.*
|
|
FROM user_prompts up
|
|
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
|
${whereClause}
|
|
${orderClause}
|
|
LIMIT ? OFFSET ?
|
|
`;
|
|
|
|
params.push(limit, offset);
|
|
return this.db.prepare(sql).all(...params) as UserPromptSearchResult[];
|
|
}
|
|
|
|
// Vector search with query text should be handled by ChromaDB
|
|
// This method only supports filter-only queries (query=undefined)
|
|
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get all prompts for a session by content_session_id
|
|
*/
|
|
getUserPromptsBySession(contentSessionId: string): UserPromptRow[] {
|
|
const stmt = this.db.prepare(`
|
|
SELECT
|
|
id,
|
|
content_session_id,
|
|
prompt_number,
|
|
prompt_text,
|
|
created_at,
|
|
created_at_epoch
|
|
FROM user_prompts
|
|
WHERE content_session_id = ?
|
|
ORDER BY prompt_number ASC
|
|
`);
|
|
|
|
return stmt.all(contentSessionId) as UserPromptRow[];
|
|
}
|
|
|
|
/**
|
|
* Close the database connection
|
|
*/
|
|
close(): void {
|
|
this.db.close();
|
|
}
|
|
}
|