@josxa/opencode-recall
Advanced tools
| import type { HistoryWorkerRequest } from './worker-protocol.js'; | ||
| export declare function executeNodeWorker(packageDir: string, request: HistoryWorkerRequest, signal: AbortSignal): Promise<string>; | ||
| export declare function executeNodeWorkerSync(packageDir: string, request: HistoryWorkerRequest): string; |
| import { Buffer } from 'node:buffer'; | ||
| import { spawn, spawnSync } from 'node:child_process'; | ||
| import { existsSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| const MAX_WORKER_OUTPUT_BYTES = 10 * 1024 * 1024; | ||
| export function executeNodeWorker(packageDir, request, signal) { | ||
| const worker = resolveWorkerCommand(packageDir); | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn(worker.command, worker.args, { | ||
| env: process.env, | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| }); | ||
| const stdout = []; | ||
| const stderr = []; | ||
| let stdoutBytes = 0; | ||
| let stderrBytes = 0; | ||
| let settled = false; | ||
| const settle = (result) => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
| settled = true; | ||
| signal.removeEventListener('abort', abort); | ||
| if ('error' in result) { | ||
| reject(result.error); | ||
| return; | ||
| } | ||
| resolve(result.value); | ||
| }; | ||
| const abort = () => { | ||
| child.kill(); | ||
| settle({ error: new Error('opencode-recall Node worker was aborted') }); | ||
| }; | ||
| signal.addEventListener('abort', abort, { once: true }); | ||
| child.on('error', (error) => settle({ error })); | ||
| child.stdout.on('data', (chunk) => { | ||
| stdoutBytes += chunk.length; | ||
| if (stdoutBytes > MAX_WORKER_OUTPUT_BYTES) { | ||
| child.kill(); | ||
| settle({ error: new Error('opencode-recall Node worker exceeded stdout limit') }); | ||
| return; | ||
| } | ||
| stdout.push(chunk); | ||
| }); | ||
| child.stderr.on('data', (chunk) => { | ||
| stderrBytes += chunk.length; | ||
| if (stderrBytes <= MAX_WORKER_OUTPUT_BYTES) { | ||
| stderr.push(chunk); | ||
| } | ||
| }); | ||
| child.on('close', (code) => { | ||
| settle(parseWorkerResult(code, stdout, stderr)); | ||
| }); | ||
| child.stdin.end(JSON.stringify(request)); | ||
| }); | ||
| } | ||
| export function executeNodeWorkerSync(packageDir, request) { | ||
| const worker = resolveWorkerCommand(packageDir); | ||
| const result = spawnSync(worker.command, worker.args, { | ||
| env: process.env, | ||
| input: JSON.stringify(request), | ||
| maxBuffer: MAX_WORKER_OUTPUT_BYTES, | ||
| }); | ||
| if (result.error !== undefined) { | ||
| throw result.error; | ||
| } | ||
| const parsed = parseWorkerResult(result.status, [result.stdout], [result.stderr]); | ||
| if ('error' in parsed) { | ||
| throw parsed.error; | ||
| } | ||
| return parsed.value; | ||
| } | ||
| function resolveWorkerCommand(packageDir) { | ||
| const builtWorkerPath = join(packageDir, 'src/node-worker.js'); | ||
| if (existsSync(builtWorkerPath)) { | ||
| return { command: 'node', args: [builtWorkerPath] }; | ||
| } | ||
| return { command: 'node', args: ['--import', 'tsx', join(packageDir, 'src/node-worker.ts')] }; | ||
| } | ||
| function parseWorkerResult(code, stdout, stderr) { | ||
| const stdoutText = Buffer.concat(stdout).toString('utf-8'); | ||
| const stderrText = Buffer.concat(stderr).toString('utf-8'); | ||
| if (code !== 0) { | ||
| return { | ||
| error: new Error(nonEmpty(stderrText) ?? nonEmpty(stdoutText) ?? `Node worker exited ${code}`), | ||
| }; | ||
| } | ||
| const response = parseWorkerResponse(stdoutText); | ||
| if (response === undefined) { | ||
| return { error: new Error(nonEmpty(stderrText) ?? 'Node worker returned invalid JSON') }; | ||
| } | ||
| if (!response.ok) { | ||
| return { error: new Error(response.error.message) }; | ||
| } | ||
| return { value: response.data }; | ||
| } | ||
| function parseWorkerResponse(value) { | ||
| try { | ||
| const parsed = JSON.parse(value); | ||
| return isHistoryWorkerResponse(parsed) ? parsed : undefined; | ||
| } | ||
| catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| function isHistoryWorkerResponse(value) { | ||
| if (!isHistoryWorkerResponseShape(value) || typeof value.ok !== 'boolean') { | ||
| return false; | ||
| } | ||
| if (value.ok) { | ||
| return typeof value.data === 'string'; | ||
| } | ||
| const error = value.error; | ||
| return isHistoryWorkerErrorShape(error) && typeof error.message === 'string'; | ||
| } | ||
| function isRecord(value) { | ||
| return value !== null && typeof value === 'object' && !Array.isArray(value); | ||
| } | ||
| function isHistoryWorkerResponseShape(value) { | ||
| return isRecord(value); | ||
| } | ||
| function isHistoryWorkerErrorShape(value) { | ||
| return isRecord(value); | ||
| } | ||
| function nonEmpty(value) { | ||
| const trimmed = value.trim(); | ||
| return trimmed.length === 0 ? undefined : trimmed; | ||
| } |
| export {}; |
| import { executeWorkerRequest } from './worker-actions.js'; | ||
| const response = await executeMain(); | ||
| process.stdout.write(JSON.stringify(response)); | ||
| async function executeMain() { | ||
| try { | ||
| return { ok: true, data: await executeWorkerRequest(parseRequest(await readStdin())) }; | ||
| } | ||
| catch (error) { | ||
| return { ok: false, error: { message: errorMessage(error) } }; | ||
| } | ||
| } | ||
| async function readStdin() { | ||
| const chunks = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| } | ||
| return Buffer.concat(chunks).toString('utf-8'); | ||
| } | ||
| function parseRequest(input) { | ||
| const parsed = JSON.parse(input); | ||
| if (isHistoryWorkerRequest(parsed)) { | ||
| return parsed; | ||
| } | ||
| throw new Error('Invalid opencode-recall worker request'); | ||
| } | ||
| function isHistoryWorkerRequest(value) { | ||
| if (!isHistoryWorkerRequestShape(value)) { | ||
| return false; | ||
| } | ||
| const kind = value.kind; | ||
| const args = value.args; | ||
| const context = value.context; | ||
| if (kind === 'read' || kind === 'read-window') { | ||
| return isRecord(args); | ||
| } | ||
| return kind === 'search' && isRecord(args) && isRecord(context); | ||
| } | ||
| function isRecord(value) { | ||
| return value !== null && typeof value === 'object' && !Array.isArray(value); | ||
| } | ||
| function isHistoryWorkerRequestShape(value) { | ||
| return isRecord(value); | ||
| } | ||
| function errorMessage(error) { | ||
| return error instanceof Error ? error.message : String(error); | ||
| } |
| import { type ReadMode, type SearchRow } from './db.js'; | ||
| import { type EmbeddingProvider } from './embedding.js'; | ||
| import { type SyncOptions, type SyncResult } from './sidecar.js'; | ||
| import type { TranscriptWindow } from './transcript.js'; | ||
| export type { EmbeddingProvider, OllamaEmbeddingProviderOptions } from './embedding.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| export type { SyncOptions, SyncResult } from './sidecar.js'; | ||
| export type { TranscriptWindow } from './transcript.js'; | ||
| export interface OpenCodeRecallOptions { | ||
| readonly historyDbPath?: string; | ||
| readonly sidecarDbPath?: string; | ||
| readonly embeddingProvider?: EmbeddingProvider; | ||
| } | ||
| export interface RecallSearchOptions { | ||
| readonly limit?: number; | ||
| readonly after?: Date | number | string; | ||
| readonly before?: Date | number | string; | ||
| readonly directory?: string; | ||
| readonly includeCurrentSession?: boolean; | ||
| readonly currentSessionId?: string; | ||
| readonly excludeSessionId?: string; | ||
| readonly semantic?: boolean; | ||
| readonly lexical?: boolean; | ||
| readonly sync?: boolean; | ||
| readonly syncOptions?: SyncOptions; | ||
| } | ||
| export interface RecallSearchHit { | ||
| readonly cursor: string; | ||
| readonly sessionId: string; | ||
| readonly sessionTitle: string; | ||
| readonly directory: string; | ||
| readonly messageId: string; | ||
| readonly partId: string; | ||
| readonly role: string; | ||
| readonly score?: number; | ||
| readonly timeCreated: number; | ||
| readonly time: string; | ||
| readonly text: string; | ||
| readonly source?: SearchRow['source']; | ||
| } | ||
| export interface RecallReadOptions { | ||
| readonly mode?: ReadMode; | ||
| readonly limit?: number; | ||
| } | ||
| export interface RecallSearchResult { | ||
| readonly hits: readonly RecallSearchHit[]; | ||
| readonly sync?: SyncResult; | ||
| } | ||
| export declare class DirectOpenCodeRecall { | ||
| #private; | ||
| constructor(options?: OpenCodeRecallOptions); | ||
| close(): void; | ||
| sync(options?: SyncOptions): Promise<SyncResult>; | ||
| syncLexical(): SyncResult; | ||
| search(query: string, options?: RecallSearchOptions): Promise<RecallSearchResult>; | ||
| read(cursorValue: string, options?: RecallReadOptions): TranscriptWindow; | ||
| render(cursorValue: string, options?: RecallReadOptions): string; | ||
| } | ||
| export declare function directSearchHistory(query: string, options?: RecallSearchOptions & OpenCodeRecallOptions): Promise<RecallSearchResult>; | ||
| export declare function directReadHistoryWindow(cursor: string, options?: RecallReadOptions & OpenCodeRecallOptions): TranscriptWindow; | ||
| export declare function directRenderHistoryWindow(cursor: string, options?: RecallReadOptions & OpenCodeRecallOptions): string; |
| import { ChatmlRenderer } from './chatml-renderer.js'; | ||
| import { decodeCursor } from './cursor.js'; | ||
| import { HistoryDatabase } from './db.js'; | ||
| import { OllamaEmbeddingProvider } from './embedding.js'; | ||
| import { normalizeWindow } from './normalizer.js'; | ||
| import { parseReadMode } from './read-mode.js'; | ||
| import { rankSearchRows } from './search.js'; | ||
| import { RecallSidecarIndex } from './sidecar.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| const DEFAULT_SEARCH_LIMIT = 50; | ||
| const DEFAULT_READ_LIMIT = 12; | ||
| const DEFAULT_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| export class DirectOpenCodeRecall { | ||
| #history; | ||
| #sidecar; | ||
| #provider; | ||
| #ownsProvider; | ||
| constructor(options = {}) { | ||
| this.#history = new HistoryDatabase(options.historyDbPath); | ||
| this.#sidecar = new RecallSidecarIndex(options.sidecarDbPath); | ||
| this.#provider = options.embeddingProvider ?? new OllamaEmbeddingProvider(); | ||
| this.#ownsProvider = options.embeddingProvider === undefined; | ||
| } | ||
| close() { | ||
| this.#sidecar.close(); | ||
| this.#history.close(); | ||
| if (this.#ownsProvider && 'close' in this.#provider) { | ||
| const close = this.#provider.close; | ||
| if (typeof close === 'function') { | ||
| close.call(this.#provider); | ||
| } | ||
| } | ||
| } | ||
| async sync(options = {}) { | ||
| return this.#sidecar.sync((since) => this.#history.readTextPartsForIndex(since), this.#provider, () => this.#history.readTextPartIds(), options); | ||
| } | ||
| // Build/refresh just the FTS5 lexical index, skipping embeddings. | ||
| // Used when callers opt out of semantic but still want lexical recall. | ||
| syncLexical() { | ||
| const start = performance.now(); | ||
| const result = this.#sidecar.syncLexicalOnly((since) => this.#history.readTextPartsForIndex(since), () => this.#history.readTextPartIds()); | ||
| return { | ||
| elapsedMs: performance.now() - start, | ||
| indexedRows: result.indexedRows, | ||
| deletedRows: result.deletedRows, | ||
| lockAcquired: result.lockAcquired, | ||
| }; | ||
| } | ||
| async search(query, options = {}) { | ||
| const searchOptions = normalizeSearchOptions(options); | ||
| if (isBlankQuery(query)) { | ||
| return { hits: this.#history.recent(searchOptions).map(toSearchHit) }; | ||
| } | ||
| const lexicalEnabled = options.lexical !== false; | ||
| const semanticEnabled = options.semantic !== false; | ||
| const shouldSync = (semanticEnabled || lexicalEnabled) && options.sync !== false; | ||
| const syncResult = shouldSync | ||
| ? semanticEnabled | ||
| ? await this.sync(options.syncOptions) | ||
| : this.syncLexical() | ||
| : undefined; | ||
| const lexicalRows = lexicalEnabled ? this.#sidecar.lexicalSearch(query, searchOptions) : []; | ||
| const semanticRows = semanticEnabled | ||
| ? await this.#sidecar.search(query, searchOptions, this.#provider) | ||
| : []; | ||
| const rows = rankSearchRows(query, [...lexicalRows, ...semanticRows], searchOptions.limit); | ||
| return { | ||
| hits: rows.map(toSearchHit), | ||
| ...(syncResult === undefined ? {} : { sync: syncResult }), | ||
| }; | ||
| } | ||
| read(cursorValue, options = {}) { | ||
| const cursor = decodeCursor(cursorValue); | ||
| const readOptions = { | ||
| mode: parseReadMode(options.mode), | ||
| limit: options.limit ?? DEFAULT_READ_LIMIT, | ||
| }; | ||
| return normalizeWindow(cursor.messageId === undefined | ||
| ? this.#history.readWindowForSession(requiredSessionId(cursor.sessionId), readOptions) | ||
| : this.#history.readWindow(cursor.messageId, readOptions)); | ||
| } | ||
| render(cursorValue, options = {}) { | ||
| return new ChatmlRenderer().render(this.read(cursorValue, options)); | ||
| } | ||
| } | ||
| export async function directSearchHistory(query, options = {}) { | ||
| const recall = new DirectOpenCodeRecall(options); | ||
| try { | ||
| return await recall.search(query, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| } | ||
| export function directReadHistoryWindow(cursor, options = {}) { | ||
| const recall = new DirectOpenCodeRecall(options); | ||
| try { | ||
| return recall.read(cursor, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| } | ||
| export function directRenderHistoryWindow(cursor, options = {}) { | ||
| const recall = new DirectOpenCodeRecall(options); | ||
| try { | ||
| return recall.render(cursor, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| } | ||
| function normalizeSearchOptions(options) { | ||
| const excluded = excludedSessionId(options); | ||
| return { | ||
| limit: options.limit ?? DEFAULT_SEARCH_LIMIT, | ||
| ...optionalTimestampFilter('after', options.after), | ||
| ...optionalTimestampFilter('before', options.before ?? defaultBefore(options)), | ||
| ...(options.directory === undefined ? {} : { directory: options.directory }), | ||
| ...(excluded === undefined ? {} : { excludeSessionId: excluded }), | ||
| }; | ||
| } | ||
| function isBlankQuery(query) { | ||
| return query.trim().length === 0; | ||
| } | ||
| function excludedSessionId(options) { | ||
| if (options.includeCurrentSession === true) { | ||
| return options.excludeSessionId; | ||
| } | ||
| return options.excludeSessionId ?? options.currentSessionId; | ||
| } | ||
| function defaultBefore(options) { | ||
| if (options.includeCurrentSession === true || options.currentSessionId === undefined) { | ||
| return undefined; | ||
| } | ||
| return Date.now() - DEFAULT_FRESHNESS_EXCLUSION_MS; | ||
| } | ||
| function optionalTimestampFilter(name, value) { | ||
| const timestamp = optionalTimestamp(value); | ||
| if (timestamp === undefined) { | ||
| return {}; | ||
| } | ||
| return { [name]: timestamp }; | ||
| } | ||
| function optionalTimestamp(value) { | ||
| if (value === undefined) { | ||
| return undefined; | ||
| } | ||
| if (value instanceof Date) { | ||
| return value.getTime(); | ||
| } | ||
| if (typeof value === 'number') { | ||
| return Number.isFinite(value) ? value : undefined; | ||
| } | ||
| if (value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = Date.parse(value); | ||
| return Number.isFinite(timestamp) ? timestamp : undefined; | ||
| } | ||
| function toSearchHit(row) { | ||
| const cursor = row.messageId; | ||
| return { | ||
| cursor, | ||
| sessionId: row.sessionId, | ||
| sessionTitle: row.sessionTitle, | ||
| directory: row.directory, | ||
| messageId: row.messageId, | ||
| partId: row.partId, | ||
| role: row.role, | ||
| ...(row.score === undefined ? {} : { score: Number(row.score.toFixed(4)) }), | ||
| timeCreated: row.timeCreated, | ||
| time: new Date(row.timeCreated).toISOString(), | ||
| text: row.text, | ||
| ...(row.source === undefined ? {} : { source: row.source }), | ||
| }; | ||
| } | ||
| function requiredSessionId(value) { | ||
| if (value !== undefined) { | ||
| return value; | ||
| } | ||
| throw new Error('History cursor does not contain a message or session id'); | ||
| } |
| import { type StatementSync } from 'node:sqlite'; | ||
| export type SqliteBindValue = string | number | bigint | null | Buffer | Uint8Array; | ||
| export type SqliteBindParams = readonly SqliteBindValue[]; | ||
| export interface SqliteRunResult { | ||
| readonly changes: number | bigint; | ||
| readonly lastInsertRowid: number | bigint; | ||
| } | ||
| export declare class Database { | ||
| #private; | ||
| constructor(path: string, options?: { | ||
| readonly?: boolean; | ||
| }); | ||
| exec(sql: string): void; | ||
| close(): void; | ||
| query<TResult, TParams extends SqliteBindParams = SqliteBindParams>(sql: string): Statement<TResult, TParams>; | ||
| prepare<TResult, TParams extends SqliteBindParams = SqliteBindParams>(sql: string): Statement<TResult, TParams>; | ||
| transaction(callback: () => void): () => void; | ||
| } | ||
| declare class Statement<TResult, TParams extends SqliteBindParams> { | ||
| #private; | ||
| constructor(statement: StatementSync); | ||
| all(...params: TParams): TResult[]; | ||
| get(...params: TParams): TResult | null; | ||
| run(...params: TParams): SqliteRunResult; | ||
| } | ||
| export {}; |
| import { DatabaseSync } from 'node:sqlite'; | ||
| export class Database { | ||
| #db; | ||
| constructor(path, options = {}) { | ||
| this.#db = new DatabaseSync(path, { | ||
| readOnly: options.readonly === true, | ||
| }); | ||
| } | ||
| exec(sql) { | ||
| this.#db.exec(sql); | ||
| } | ||
| close() { | ||
| this.#db.close(); | ||
| } | ||
| query(sql) { | ||
| return new Statement(this.#db.prepare(sql)); | ||
| } | ||
| prepare(sql) { | ||
| return this.query(sql); | ||
| } | ||
| transaction(callback) { | ||
| return () => { | ||
| this.#db.exec('begin'); | ||
| try { | ||
| callback(); | ||
| this.#db.exec('commit'); | ||
| } | ||
| catch (error) { | ||
| this.#db.exec('rollback'); | ||
| throw error; | ||
| } | ||
| }; | ||
| } | ||
| } | ||
| class Statement { | ||
| #statement; | ||
| constructor(statement) { | ||
| this.#statement = statement; | ||
| } | ||
| all(...params) { | ||
| return this.#statement.all(...params); | ||
| } | ||
| get(...params) { | ||
| return this.#statement.get(...params) ?? null; | ||
| } | ||
| run(...params) { | ||
| return this.#statement.run(...params); | ||
| } | ||
| } |
| export declare const DEFAULT_SEARCH_LIMIT = 8; | ||
| export declare const MAX_SEARCH_LIMIT = 25; | ||
| export declare const DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS = 30000; | ||
| export declare const DEFAULT_READ_LIMIT = 12; | ||
| export declare const MAX_READ_LIMIT = 50; |
| export const DEFAULT_SEARCH_LIMIT = 8; | ||
| export const MAX_SEARCH_LIMIT = 25; | ||
| export const DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| export const DEFAULT_READ_LIMIT = 12; | ||
| export const MAX_READ_LIMIT = 50; |
| import type { HistoryWorkerRequest } from './worker-protocol.js'; | ||
| export declare function executeWorkerRequest(request: HistoryWorkerRequest): Promise<string>; |
| import { ChatmlRenderer } from './chatml-renderer.js'; | ||
| import { decodeCursor } from './cursor.js'; | ||
| import { HistoryDatabase } from './db.js'; | ||
| import { OllamaEmbeddingProvider } from './embedding.js'; | ||
| import { normalizeWindow } from './normalizer.js'; | ||
| import { parseReadMode } from './read-mode.js'; | ||
| import { formatSearchResults, rankSearchRows } from './search.js'; | ||
| import { RecallSidecarIndex } from './sidecar.js'; | ||
| import { DEFAULT_READ_LIMIT, DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS, DEFAULT_SEARCH_LIMIT, MAX_READ_LIMIT, MAX_SEARCH_LIMIT, } from './tool-defaults.js'; | ||
| export async function executeWorkerRequest(request) { | ||
| if (request.kind === 'search') { | ||
| return executeHistorySearch(request); | ||
| } | ||
| const window = executeHistoryReadWindow(request); | ||
| if (request.kind === 'read-window') { | ||
| return JSON.stringify(window); | ||
| } | ||
| return new ChatmlRenderer().render(window); | ||
| } | ||
| async function executeHistorySearch(request) { | ||
| const query = request.args.q ?? ''; | ||
| const includeCurrentSession = request.args.includeCurrentSession === true; | ||
| const semanticEnabled = request.args.semantic !== false; | ||
| const lexicalEnabled = request.args.lexical !== false; | ||
| const shouldSync = (semanticEnabled || lexicalEnabled) && request.args.sync !== false; | ||
| const before = optionalDateFilterValue('before', request.args.before); | ||
| const options = { | ||
| limit: clampNumber(request.args.n, DEFAULT_SEARCH_LIMIT, 1, MAX_SEARCH_LIMIT), | ||
| ...optionalStringFilter('directory', request.args.directory), | ||
| ...currentSessionExclusion(includeCurrentSession, request.context.sessionID, request.args.excludeSessionId), | ||
| ...optionalDateFilter('after', request.args.after), | ||
| ...searchBeforeFilter(before, includeCurrentSession), | ||
| }; | ||
| const db = new HistoryDatabase(request.args.historyDbPath); | ||
| const sidecar = new RecallSidecarIndex(request.args.sidecarDbPath); | ||
| try { | ||
| if (query.trim().length === 0) { | ||
| return formatSearchResults(db.recent(options)); | ||
| } | ||
| const syncStart = performance.now(); | ||
| const provider = semanticEnabled ? new OllamaEmbeddingProvider() : undefined; | ||
| const syncResult = shouldSync | ||
| ? semanticEnabled && provider !== undefined | ||
| ? await sidecar.sync((since) => db.readTextPartsForIndex(since), provider, () => db.readTextPartIds()) | ||
| : { | ||
| ...sidecar.syncLexicalOnly((since) => db.readTextPartsForIndex(since), () => db.readTextPartIds()), | ||
| elapsedMs: performance.now() - syncStart, | ||
| } | ||
| : undefined; | ||
| const [semanticRows, lexicalRows] = await Promise.all([ | ||
| semanticEnabled && provider !== undefined | ||
| ? sidecar.search(query, options, provider) | ||
| : Promise.resolve([]), | ||
| Promise.resolve(lexicalEnabled ? sidecar.lexicalSearch(query, options) : []), | ||
| ]); | ||
| const rows = rankSearchRows(query, [...lexicalRows, ...semanticRows], options.limit); | ||
| return formatSearchResults(rows, syncResult); | ||
| } | ||
| finally { | ||
| sidecar.close(); | ||
| db.close(); | ||
| } | ||
| } | ||
| function executeHistoryReadWindow(request) { | ||
| const cursorValue = request.args.cursor; | ||
| if (cursorValue === undefined || cursorValue.length === 0) { | ||
| throw new Error('history_read requires cursor'); | ||
| } | ||
| const cursor = decodeCursor(cursorValue); | ||
| const mode = parseReadMode(request.args.mode); | ||
| const limit = clampNumber(request.args.n, DEFAULT_READ_LIMIT, 1, MAX_READ_LIMIT); | ||
| const db = new HistoryDatabase(request.args.historyDbPath); | ||
| try { | ||
| const readOptions = { mode, limit }; | ||
| return normalizeWindow(cursor.messageId === undefined | ||
| ? db.readWindowForSession(requiredSessionId(cursor.sessionId), readOptions) | ||
| : db.readWindow(cursor.messageId, readOptions)); | ||
| } | ||
| finally { | ||
| db.close(); | ||
| } | ||
| } | ||
| function optionalDateFilter(name, value) { | ||
| const timestamp = optionalDateFilterValue(name, value); | ||
| if (timestamp === undefined) { | ||
| return {}; | ||
| } | ||
| return { [name]: timestamp }; | ||
| } | ||
| function optionalDateFilterValue(name, value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = optionalTimestamp(value); | ||
| if (timestamp === undefined) { | ||
| throw new Error(`Invalid ${name} date filter: ${value}`); | ||
| } | ||
| return timestamp; | ||
| } | ||
| function optionalTimestamp(value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = Date.parse(value); | ||
| return Number.isFinite(timestamp) ? timestamp : undefined; | ||
| } | ||
| function optionalStringFilter(name, value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return {}; | ||
| } | ||
| return { [name]: value }; | ||
| } | ||
| function currentSessionExclusion(includeCurrentSession, sessionID, excludeSessionId) { | ||
| if (includeCurrentSession === true) { | ||
| return excludeSessionId === undefined ? {} : { excludeSessionId }; | ||
| } | ||
| return { excludeSessionId: excludeSessionId ?? sessionID }; | ||
| } | ||
| function searchBeforeFilter(before, includeCurrentSession) { | ||
| if (before !== undefined) { | ||
| return { before }; | ||
| } | ||
| if (includeCurrentSession) { | ||
| return {}; | ||
| } | ||
| return { before: Date.now() - DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS }; | ||
| } | ||
| function clampNumber(value, fallback, min, max) { | ||
| if (value === undefined || !Number.isFinite(value)) { | ||
| return fallback; | ||
| } | ||
| return Math.min(max, Math.max(min, Math.trunc(value))); | ||
| } | ||
| function requiredSessionId(value) { | ||
| if (value !== undefined) { | ||
| return value; | ||
| } | ||
| throw new Error('History cursor does not contain a message or session id'); | ||
| } |
| export interface HistorySearchWorkerArgs { | ||
| readonly q?: string | undefined; | ||
| readonly n?: number | undefined; | ||
| readonly directory?: string | undefined; | ||
| readonly includeCurrentSession?: boolean | undefined; | ||
| readonly excludeSessionId?: string | undefined; | ||
| readonly after?: string | undefined; | ||
| readonly before?: string | undefined; | ||
| readonly historyDbPath?: string | undefined; | ||
| readonly sidecarDbPath?: string | undefined; | ||
| readonly semantic?: boolean | undefined; | ||
| readonly lexical?: boolean | undefined; | ||
| readonly sync?: boolean | undefined; | ||
| } | ||
| export interface HistoryReadWorkerArgs { | ||
| readonly cursor?: string | undefined; | ||
| readonly mode?: string | undefined; | ||
| readonly n?: number | undefined; | ||
| readonly historyDbPath?: string | undefined; | ||
| } | ||
| export type HistoryWorkerRequest = { | ||
| readonly kind: 'search'; | ||
| readonly args: HistorySearchWorkerArgs; | ||
| readonly context: { | ||
| readonly sessionID: string; | ||
| }; | ||
| } | { | ||
| readonly kind: 'read'; | ||
| readonly args: HistoryReadWorkerArgs; | ||
| } | { | ||
| readonly kind: 'read-window'; | ||
| readonly args: HistoryReadWorkerArgs; | ||
| }; | ||
| export type HistoryWorkerResponse = { | ||
| readonly ok: true; | ||
| readonly data: string; | ||
| } | { | ||
| readonly ok: false; | ||
| readonly error: { | ||
| readonly message: string; | ||
| }; | ||
| }; |
| export {}; |
+22
-123
@@ -0,19 +1,12 @@ | ||
| import { readFile } from 'node:fs/promises'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { tool } from '@opencode-ai/plugin'; | ||
| import { ChatmlRenderer } from './src/chatml-renderer'; | ||
| import { HISTORY_READ_COMMAND, HISTORY_SEARCH_COMMAND, RECALL_AGENT_DESCRIPTION, RECALL_AGENT_NAME, } from './src/commands'; | ||
| import { decodeCursor } from './src/cursor'; | ||
| import { HistoryDatabase } from './src/db'; | ||
| import { OllamaEmbeddingProvider } from './src/embedding'; | ||
| import { normalizeWindow } from './src/normalizer'; | ||
| import { parseReadMode } from './src/read-mode'; | ||
| import { formatSearchResults, rankSearchRows } from './src/search'; | ||
| import { RecallSidecarIndex } from './src/sidecar'; | ||
| const DEFAULT_SEARCH_LIMIT = 8; | ||
| const MAX_SEARCH_LIMIT = 25; | ||
| const DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| const DEFAULT_READ_LIMIT = 12; | ||
| const MAX_READ_LIMIT = 50; | ||
| import { HISTORY_READ_COMMAND, HISTORY_SEARCH_COMMAND, RECALL_AGENT_DESCRIPTION, RECALL_AGENT_NAME, } from './src/commands.js'; | ||
| import { executeNodeWorker } from './src/node-worker-client.js'; | ||
| import { DEFAULT_READ_LIMIT, DEFAULT_SEARCH_LIMIT } from './src/tool-defaults.js'; | ||
| const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)); | ||
| const RECALL_AGENT_PROMPT_PATHS = [ | ||
| `${import.meta.dir}/prompts/recall-agent-prompt.txt`, | ||
| `${import.meta.dir}/../prompts/recall-agent-prompt.txt`, | ||
| join(PACKAGE_DIR, 'prompts/recall-agent-prompt.txt'), | ||
| join(PACKAGE_DIR, '../prompts/recall-agent-prompt.txt'), | ||
| ]; | ||
@@ -58,31 +51,3 @@ export const RecallPlugin = async () => { | ||
| assertRecallAgent(context.agent); | ||
| const query = args.q ?? ''; | ||
| const includeCurrentSession = args.includeCurrentSession === true; | ||
| const before = optionalDateFilterValue('before', args.before); | ||
| const options = { | ||
| limit: clampNumber(args.n, DEFAULT_SEARCH_LIMIT, 1, MAX_SEARCH_LIMIT), | ||
| ...optionalStringFilter('directory', args.directory), | ||
| ...currentSessionExclusion(includeCurrentSession, context.sessionID), | ||
| ...optionalDateFilter('after', args.after), | ||
| ...searchBeforeFilter(before, includeCurrentSession), | ||
| }; | ||
| const db = new HistoryDatabase(); | ||
| const sidecar = new RecallSidecarIndex(); | ||
| try { | ||
| if (query.trim().length === 0) { | ||
| return formatSearchResults(db.recent(options)); | ||
| } | ||
| const provider = new OllamaEmbeddingProvider(); | ||
| const syncResult = await sidecar.sync((since) => db.readTextPartsForIndex(since), provider, () => db.readTextPartIds()); | ||
| const [semanticRows, lexicalRows] = await Promise.all([ | ||
| sidecar.search(query, options, provider), | ||
| Promise.resolve(sidecar.lexicalSearch(query, options)), | ||
| ]); | ||
| const rows = rankSearchRows(query, [...lexicalRows, ...semanticRows], options.limit); | ||
| return formatSearchResults(rows, syncResult); | ||
| } | ||
| finally { | ||
| sidecar.close(); | ||
| db.close(); | ||
| } | ||
| return executeNodeWorker(PACKAGE_DIR, { kind: 'search', args, context: { sessionID: context.sessionID } }, context.abort); | ||
| }, | ||
@@ -108,20 +73,3 @@ }), | ||
| assertRecallAgent(context.agent); | ||
| const cursorValue = args.cursor; | ||
| if (cursorValue === undefined || cursorValue.length === 0) { | ||
| throw new Error('history_read requires cursor'); | ||
| } | ||
| const cursor = decodeCursor(cursorValue); | ||
| const mode = parseReadMode(args.mode); | ||
| const limit = clampNumber(args.n, DEFAULT_READ_LIMIT, 1, MAX_READ_LIMIT); | ||
| const db = new HistoryDatabase(); | ||
| try { | ||
| const readOptions = { mode, limit }; | ||
| const window = normalizeWindow(cursor.messageId === undefined | ||
| ? db.readWindowForSession(requiredSessionId(cursor.sessionId), readOptions) | ||
| : db.readWindow(cursor.messageId, readOptions)); | ||
| return new ChatmlRenderer().render(window); | ||
| } | ||
| finally { | ||
| db.close(); | ||
| } | ||
| return executeNodeWorker(PACKAGE_DIR, { kind: 'read', args }, context.abort); | ||
| }, | ||
@@ -134,9 +82,17 @@ }), | ||
| for (const path of RECALL_AGENT_PROMPT_PATHS) { | ||
| const file = Bun.file(path); | ||
| if (await file.exists()) { | ||
| return file.text(); | ||
| try { | ||
| return await readFile(path, 'utf-8'); | ||
| } | ||
| catch (error) { | ||
| if (isMissingFileError(error)) { | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| throw new Error('Cannot load recall-agent-prompt.txt from package prompts directory'); | ||
| } | ||
| function isMissingFileError(error) { | ||
| return error instanceof Error && 'code' in error && error.code === 'ENOENT'; | ||
| } | ||
| function recallAgentPermission() { | ||
@@ -155,59 +111,2 @@ return { | ||
| } | ||
| function requiredSessionId(value) { | ||
| if (value !== undefined) { | ||
| return value; | ||
| } | ||
| throw new Error('History cursor does not contain a message or session id'); | ||
| } | ||
| export default RecallPlugin; | ||
| function optionalDateFilter(name, value) { | ||
| const timestamp = optionalDateFilterValue(name, value); | ||
| if (timestamp === undefined) { | ||
| return {}; | ||
| } | ||
| return { [name]: timestamp }; | ||
| } | ||
| function optionalDateFilterValue(name, value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = optionalTimestamp(value); | ||
| if (timestamp === undefined) { | ||
| throw new Error(`Invalid ${name} date filter: ${value}`); | ||
| } | ||
| return timestamp; | ||
| } | ||
| function optionalTimestamp(value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = Date.parse(value); | ||
| return Number.isFinite(timestamp) ? timestamp : undefined; | ||
| } | ||
| function optionalStringFilter(name, value) { | ||
| if (value === undefined || value.length === 0) { | ||
| return {}; | ||
| } | ||
| return { [name]: value }; | ||
| } | ||
| function currentSessionExclusion(includeCurrentSession, sessionID) { | ||
| if (includeCurrentSession === true) { | ||
| return {}; | ||
| } | ||
| return { excludeSessionId: sessionID }; | ||
| } | ||
| function searchBeforeFilter(before, includeCurrentSession) { | ||
| if (before !== undefined) { | ||
| return { before }; | ||
| } | ||
| if (includeCurrentSession) { | ||
| return {}; | ||
| } | ||
| return { before: Date.now() - DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS }; | ||
| } | ||
| function clampNumber(value, fallback, min, max) { | ||
| if (value === undefined || !Number.isFinite(value)) { | ||
| return fallback; | ||
| } | ||
| return Math.min(max, Math.max(min, Math.trunc(value))); | ||
| } |
@@ -1,2 +0,2 @@ | ||
| import type { HistoryRenderer, TranscriptWindow } from './transcript'; | ||
| import type { HistoryRenderer, TranscriptWindow } from './transcript.js'; | ||
| export declare class ChatmlRenderer implements HistoryRenderer<string> { | ||
@@ -3,0 +3,0 @@ readonly format = "chatml"; |
@@ -185,3 +185,3 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; | ||
| // Embedding model. Try "mxbai-embed-large" for higher quality at the cost | ||
| // of speed and memory. Run \`bun run eval:embeddings\` to compare models | ||
| // of speed and memory. Run \`pnpm run eval:embeddings\` to compare models | ||
| // against the regression cases in docs/real-history-regressions.md. | ||
@@ -188,0 +188,0 @@ // Default: all-minilm |
+2
-2
@@ -1,3 +0,3 @@ | ||
| import { Database } from 'bun:sqlite'; | ||
| import { loadConfig } from './config'; | ||
| import { loadConfig } from './config.js'; | ||
| import { Database } from './sqlite.js'; | ||
| const WHITESPACE_REGEX = /\s+/u; | ||
@@ -4,0 +4,0 @@ export class HistoryDatabase { |
@@ -1,2 +0,3 @@ | ||
| import { loadConfig } from './config'; | ||
| import { spawn } from 'node:child_process'; | ||
| import { loadConfig } from './config.js'; | ||
| const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434'; | ||
@@ -67,7 +68,5 @@ const DEFAULT_EMBED_MODEL = 'all-minilm'; | ||
| try { | ||
| this.#serverProcess = Bun.spawn({ | ||
| cmd: ['ollama', 'serve'], | ||
| this.#serverProcess = spawn('ollama', ['serve'], { | ||
| env: { ...process.env, OLLAMA_HOST: ollamaHost(this.#baseUrl) }, | ||
| stdout: 'ignore', | ||
| stderr: 'ignore', | ||
| stdio: 'ignore', | ||
| }); | ||
@@ -83,3 +82,3 @@ this.#serverProcess.unref(); | ||
| while (Date.now() < deadline) { | ||
| await Bun.sleep(OLLAMA_STARTUP_POLL_MS); | ||
| await sleep(OLLAMA_STARTUP_POLL_MS); | ||
| if (await this.#isServerReady()) { | ||
@@ -156,2 +155,7 @@ return true; | ||
| } | ||
| function sleep(ms) { | ||
| return new Promise((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
| async function parseEmbeddingResponse(response) { | ||
@@ -158,0 +162,0 @@ const body = (await response.json()); |
@@ -1,3 +0,3 @@ | ||
| import type { Database } from 'bun:sqlite'; | ||
| import type { IndexSourceRow, SearchOptions, SearchRow } from './db'; | ||
| import type { IndexSourceRow, SearchOptions, SearchRow } from './db.js'; | ||
| import type { Database } from './sqlite.js'; | ||
| export declare class LexicalIndex { | ||
@@ -4,0 +4,0 @@ #private; |
@@ -1,3 +0,3 @@ | ||
| import type { WindowRows } from './db'; | ||
| import type { TranscriptWindow } from './transcript'; | ||
| import type { WindowRows } from './db.js'; | ||
| import type { TranscriptWindow } from './transcript.js'; | ||
| export declare function normalizeWindow(rows: WindowRows): TranscriptWindow; |
@@ -1,2 +0,2 @@ | ||
| import { encodeCursor } from './cursor'; | ||
| import { encodeCursor } from './cursor.js'; | ||
| const MAX_TOOL_INPUT_CHARS = 2_000; | ||
@@ -3,0 +3,0 @@ const MAX_TOOL_OUTPUT_CHARS = 6_000; |
@@ -1,3 +0,3 @@ | ||
| import type { ReadMode } from './db'; | ||
| import type { ReadMode } from './db.js'; | ||
| export declare const FULL_MODE_RECOMMENDATION = "history_read mode=full is not supported for LLM-safe reading. Use mode=tail to read from the end, mode=head to read from the start, and page with the returned <nav prev=\"...\"> or <nav next=\"...\"> cursors."; | ||
| export declare function parseReadMode(value: string | undefined): ReadMode; |
+9
-49
@@ -1,49 +0,9 @@ | ||
| import { type ReadMode, type SearchRow } from './db'; | ||
| import { type EmbeddingProvider } from './embedding'; | ||
| import { type SyncOptions, type SyncResult } from './sidecar'; | ||
| import type { TranscriptWindow } from './transcript'; | ||
| export type { EmbeddingProvider, OllamaEmbeddingProviderOptions } from './embedding'; | ||
| export { OllamaEmbeddingProvider } from './embedding'; | ||
| export type { SyncOptions, SyncResult } from './sidecar'; | ||
| export type { TranscriptWindow } from './transcript'; | ||
| export interface OpenCodeRecallOptions { | ||
| readonly historyDbPath?: string; | ||
| readonly sidecarDbPath?: string; | ||
| readonly embeddingProvider?: EmbeddingProvider; | ||
| } | ||
| export interface RecallSearchOptions { | ||
| readonly limit?: number; | ||
| readonly after?: Date | number | string; | ||
| readonly before?: Date | number | string; | ||
| readonly directory?: string; | ||
| readonly includeCurrentSession?: boolean; | ||
| readonly currentSessionId?: string; | ||
| readonly excludeSessionId?: string; | ||
| readonly semantic?: boolean; | ||
| readonly lexical?: boolean; | ||
| readonly sync?: boolean; | ||
| readonly syncOptions?: SyncOptions; | ||
| } | ||
| export interface RecallSearchHit { | ||
| readonly cursor: string; | ||
| readonly sessionId: string; | ||
| readonly sessionTitle: string; | ||
| readonly directory: string; | ||
| readonly messageId: string; | ||
| readonly partId: string; | ||
| readonly role: string; | ||
| readonly score?: number; | ||
| readonly timeCreated: number; | ||
| readonly time: string; | ||
| readonly text: string; | ||
| readonly source?: SearchRow['source']; | ||
| } | ||
| export interface RecallReadOptions { | ||
| readonly mode?: ReadMode; | ||
| readonly limit?: number; | ||
| } | ||
| export interface RecallSearchResult { | ||
| readonly hits: readonly RecallSearchHit[]; | ||
| readonly sync?: SyncResult; | ||
| } | ||
| import type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchOptions, RecallSearchResult } from './sdk-direct.js'; | ||
| import type { SyncOptions, SyncResult } from './sidecar.js'; | ||
| import type { TranscriptWindow } from './transcript.js'; | ||
| export type { EmbeddingProvider, OllamaEmbeddingProviderOptions } from './embedding.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| export type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchHit, RecallSearchOptions, RecallSearchResult, } from './sdk-direct.js'; | ||
| export type { SyncOptions, SyncResult } from './sidecar.js'; | ||
| export type { TranscriptWindow } from './transcript.js'; | ||
| export declare class OpenCodeRecall { | ||
@@ -54,3 +14,3 @@ #private; | ||
| sync(options?: SyncOptions): Promise<SyncResult>; | ||
| syncLexical(): SyncResult; | ||
| syncLexical(): Promise<SyncResult>; | ||
| search(query: string, options?: RecallSearchOptions): Promise<RecallSearchResult>; | ||
@@ -57,0 +17,0 @@ read(cursorValue: string, options?: RecallReadOptions): TranscriptWindow; |
+172
-134
@@ -1,131 +0,203 @@ | ||
| import { ChatmlRenderer } from './chatml-renderer'; | ||
| import { decodeCursor } from './cursor'; | ||
| import { HistoryDatabase } from './db'; | ||
| import { OllamaEmbeddingProvider } from './embedding'; | ||
| import { normalizeWindow } from './normalizer'; | ||
| import { parseReadMode } from './read-mode'; | ||
| import { rankSearchRows } from './search'; | ||
| import { RecallSidecarIndex } from './sidecar'; | ||
| export { OllamaEmbeddingProvider } from './embedding'; | ||
| import { dirname } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { decodeCursor } from './cursor.js'; | ||
| import { executeNodeWorker, executeNodeWorkerSync } from './node-worker-client.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| const DEFAULT_SEARCH_LIMIT = 50; | ||
| const DEFAULT_READ_LIMIT = 12; | ||
| const DEFAULT_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url))); | ||
| export class OpenCodeRecall { | ||
| #history; | ||
| #sidecar; | ||
| #provider; | ||
| #ownsProvider; | ||
| #options; | ||
| constructor(options = {}) { | ||
| this.#history = new HistoryDatabase(options.historyDbPath); | ||
| this.#sidecar = new RecallSidecarIndex(options.sidecarDbPath); | ||
| this.#provider = options.embeddingProvider ?? new OllamaEmbeddingProvider(); | ||
| this.#ownsProvider = options.embeddingProvider === undefined; | ||
| this.#options = options; | ||
| } | ||
| close() { | ||
| this.#sidecar.close(); | ||
| this.#history.close(); | ||
| if (this.#ownsProvider && 'close' in this.#provider) { | ||
| const close = this.#provider.close; | ||
| if (typeof close === 'function') { | ||
| close.call(this.#provider); | ||
| } | ||
| } | ||
| // Worker-backed SDK calls do not keep resources open in the caller process. | ||
| } | ||
| async sync(options = {}) { | ||
| return this.#sidecar.sync((since) => this.#history.readTextPartsForIndex(since), this.#provider, () => this.#history.readTextPartIds(), options); | ||
| return this.#direct().then((recall) => recall.sync(options)); | ||
| } | ||
| // Build/refresh just the FTS5 lexical index, skipping embeddings. | ||
| // Used when callers opt out of semantic but still want lexical recall. | ||
| syncLexical() { | ||
| const start = performance.now(); | ||
| const result = this.#sidecar.syncLexicalOnly((since) => this.#history.readTextPartsForIndex(since), () => this.#history.readTextPartIds()); | ||
| return { | ||
| elapsedMs: performance.now() - start, | ||
| indexedRows: result.indexedRows, | ||
| deletedRows: result.deletedRows, | ||
| lockAcquired: result.lockAcquired, | ||
| }; | ||
| async syncLexical() { | ||
| return this.#direct().then((recall) => recall.syncLexical()); | ||
| } | ||
| async search(query, options = {}) { | ||
| const searchOptions = normalizeSearchOptions(options); | ||
| if (isBlankQuery(query)) { | ||
| return { hits: this.#history.recent(searchOptions).map(toSearchHit) }; | ||
| if (this.#options.embeddingProvider !== undefined) { | ||
| return this.#direct().then((recall) => recall.search(query, options)); | ||
| } | ||
| const lexicalEnabled = options.lexical !== false; | ||
| const semanticEnabled = options.semantic !== false; | ||
| const shouldSync = (semanticEnabled || lexicalEnabled) && options.sync !== false; | ||
| const syncResult = shouldSync | ||
| ? semanticEnabled | ||
| ? await this.sync(options.syncOptions) | ||
| : this.syncLexical() | ||
| : undefined; | ||
| const lexicalRows = lexicalEnabled ? this.#sidecar.lexicalSearch(query, searchOptions) : []; | ||
| const semanticRows = semanticEnabled | ||
| ? await this.#sidecar.search(query, searchOptions, this.#provider) | ||
| : []; | ||
| const rows = rankSearchRows(query, [...lexicalRows, ...semanticRows], searchOptions.limit); | ||
| return { | ||
| hits: rows.map(toSearchHit), | ||
| ...(syncResult === undefined ? {} : { sync: syncResult }), | ||
| }; | ||
| return searchHistory(query, { ...this.#options, ...options }); | ||
| } | ||
| read(cursorValue, options = {}) { | ||
| const cursor = decodeCursor(cursorValue); | ||
| const readOptions = { | ||
| mode: parseReadMode(options.mode), | ||
| limit: options.limit ?? DEFAULT_READ_LIMIT, | ||
| }; | ||
| return normalizeWindow(cursor.messageId === undefined | ||
| ? this.#history.readWindowForSession(requiredSessionId(cursor.sessionId), readOptions) | ||
| : this.#history.readWindow(cursor.messageId, readOptions)); | ||
| return readHistoryWindow(cursorValue, { ...this.#options, ...options }); | ||
| } | ||
| render(cursorValue, options = {}) { | ||
| return new ChatmlRenderer().render(this.read(cursorValue, options)); | ||
| return renderHistoryWindow(cursorValue, { ...this.#options, ...options }); | ||
| } | ||
| async #direct() { | ||
| const { DirectOpenCodeRecall } = await import('./sdk-direct.js'); | ||
| const recall = new DirectOpenCodeRecall(this.#options); | ||
| return { | ||
| sync: async (options) => { | ||
| try { | ||
| return await recall.sync(options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| syncLexical: () => { | ||
| try { | ||
| return recall.syncLexical(); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| search: async (query, options) => { | ||
| try { | ||
| return await recall.search(query, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| read: (cursor, options) => { | ||
| try { | ||
| return recall.read(cursor, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| render: (cursor, options) => { | ||
| try { | ||
| return recall.render(cursor, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
| export async function searchHistory(query, options = {}) { | ||
| const recall = new OpenCodeRecall(options); | ||
| try { | ||
| return await recall.search(query, options); | ||
| if (options.embeddingProvider !== undefined) { | ||
| const { directSearchHistory } = await import('./sdk-direct.js'); | ||
| return directSearchHistory(query, options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| const raw = await executeNodeWorker(PACKAGE_DIR, { | ||
| kind: 'search', | ||
| args: searchWorkerArgs(query, options), | ||
| context: { sessionID: options.currentSessionId ?? '' }, | ||
| }, AbortSignal.timeout(120_000)); | ||
| return { hits: parseWorkerSearchHits(raw) }; | ||
| } | ||
| export function readHistoryWindow(cursor, options = {}) { | ||
| const recall = new OpenCodeRecall(options); | ||
| try { | ||
| return recall.read(cursor, options); | ||
| const parsed = JSON.parse(executeNodeWorkerSync(PACKAGE_DIR, { | ||
| kind: 'read-window', | ||
| args: readWorkerArgs(cursor, options), | ||
| })); | ||
| if (isTranscriptWindow(parsed)) { | ||
| return parsed; | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| throw new Error('Node worker returned invalid transcript window JSON'); | ||
| } | ||
| export function renderHistoryWindow(cursor, options = {}) { | ||
| const recall = new OpenCodeRecall(options); | ||
| try { | ||
| return recall.render(cursor, options); | ||
| return executeNodeWorkerSync(PACKAGE_DIR, { | ||
| kind: 'read', | ||
| args: readWorkerArgs(cursor, options), | ||
| }); | ||
| } | ||
| function readWorkerArgs(cursor, options) { | ||
| return { | ||
| cursor, | ||
| mode: options.mode, | ||
| n: options.limit, | ||
| historyDbPath: options.historyDbPath, | ||
| }; | ||
| } | ||
| function searchWorkerArgs(query, options) { | ||
| return { | ||
| q: query, | ||
| n: options.limit ?? DEFAULT_SEARCH_LIMIT, | ||
| directory: options.directory, | ||
| includeCurrentSession: options.includeCurrentSession, | ||
| excludeSessionId: options.excludeSessionId ?? defaultExcludedSessionId(options), | ||
| after: optionalDateArg(options.after), | ||
| before: optionalDateArg(options.before ?? defaultBefore(options)), | ||
| historyDbPath: options.historyDbPath, | ||
| sidecarDbPath: options.sidecarDbPath, | ||
| semantic: options.semantic, | ||
| lexical: options.lexical, | ||
| sync: options.sync, | ||
| }; | ||
| } | ||
| function parseWorkerSearchHits(value) { | ||
| const json = searchJsonPayload(value); | ||
| if (json === undefined) { | ||
| return []; | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| const parsed = JSON.parse(json); | ||
| if (!Array.isArray(parsed)) { | ||
| return []; | ||
| } | ||
| return parsed.flatMap((item) => (isHistorySearchResult(item) ? [toSearchHit(item)] : [])); | ||
| } | ||
| function normalizeSearchOptions(options) { | ||
| const excluded = excludedSessionId(options); | ||
| function searchJsonPayload(value) { | ||
| const start = value.indexOf('['); | ||
| if (start === -1) { | ||
| return undefined; | ||
| } | ||
| return value.slice(start); | ||
| } | ||
| function isHistorySearchResult(value) { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| const result = value; | ||
| return (typeof result.cursor === 'string' && | ||
| typeof result.sid === 'string' && | ||
| typeof result.title === 'string' && | ||
| typeof result.directory === 'string' && | ||
| typeof result.time === 'string' && | ||
| typeof result.role === 'string' && | ||
| typeof result.text === 'string'); | ||
| } | ||
| function toSearchHit(result) { | ||
| const cursor = decodeCursor(result.cursor); | ||
| const timeCreated = Date.parse(result.time); | ||
| return { | ||
| limit: options.limit ?? DEFAULT_SEARCH_LIMIT, | ||
| ...optionalTimestampFilter('after', options.after), | ||
| ...optionalTimestampFilter('before', options.before ?? defaultBefore(options)), | ||
| ...(options.directory === undefined ? {} : { directory: options.directory }), | ||
| ...(excluded === undefined ? {} : { excludeSessionId: excluded }), | ||
| cursor: result.cursor, | ||
| sessionId: result.sid, | ||
| sessionTitle: result.title, | ||
| directory: result.directory, | ||
| messageId: cursor.messageId ?? '', | ||
| partId: cursor.partId ?? '', | ||
| role: result.role, | ||
| ...(result.score === undefined ? {} : { score: result.score }), | ||
| timeCreated: Number.isFinite(timeCreated) ? timeCreated : 0, | ||
| time: result.time, | ||
| text: result.text, | ||
| ...(result.source === undefined ? {} : { source: result.source }), | ||
| }; | ||
| } | ||
| function isBlankQuery(query) { | ||
| return query.trim().length === 0; | ||
| function isTranscriptWindow(value) { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| const window = value; | ||
| return (typeof window.sessionId === 'string' && | ||
| typeof window.directory === 'string' && | ||
| typeof window.mode === 'string' && | ||
| typeof window.startIndex === 'number' && | ||
| typeof window.endIndex === 'number' && | ||
| typeof window.anchorIndex === 'number' && | ||
| typeof window.anchorCursor === 'string' && | ||
| typeof window.totalMessages === 'number' && | ||
| Array.isArray(window.messages)); | ||
| } | ||
| function excludedSessionId(options) { | ||
| function defaultExcludedSessionId(options) { | ||
| if (options.includeCurrentSession === true) { | ||
| return options.excludeSessionId; | ||
| return undefined; | ||
| } | ||
| return options.excludeSessionId ?? options.currentSessionId; | ||
| return options.currentSessionId; | ||
| } | ||
@@ -138,10 +210,3 @@ function defaultBefore(options) { | ||
| } | ||
| function optionalTimestampFilter(name, value) { | ||
| const timestamp = optionalTimestamp(value); | ||
| if (timestamp === undefined) { | ||
| return {}; | ||
| } | ||
| return { [name]: timestamp }; | ||
| } | ||
| function optionalTimestamp(value) { | ||
| function optionalDateArg(value) { | ||
| if (value === undefined) { | ||
@@ -151,35 +216,8 @@ return undefined; | ||
| if (value instanceof Date) { | ||
| return value.getTime(); | ||
| return value.toISOString(); | ||
| } | ||
| if (typeof value === 'number') { | ||
| return Number.isFinite(value) ? value : undefined; | ||
| return Number.isFinite(value) ? new Date(value).toISOString() : undefined; | ||
| } | ||
| if (value.length === 0) { | ||
| return undefined; | ||
| } | ||
| const timestamp = Date.parse(value); | ||
| return Number.isFinite(timestamp) ? timestamp : undefined; | ||
| return value.length === 0 ? undefined : value; | ||
| } | ||
| function toSearchHit(row) { | ||
| const cursor = row.messageId; | ||
| return { | ||
| cursor, | ||
| sessionId: row.sessionId, | ||
| sessionTitle: row.sessionTitle, | ||
| directory: row.directory, | ||
| messageId: row.messageId, | ||
| partId: row.partId, | ||
| role: row.role, | ||
| ...(row.score === undefined ? {} : { score: Number(row.score.toFixed(4)) }), | ||
| timeCreated: row.timeCreated, | ||
| time: new Date(row.timeCreated).toISOString(), | ||
| text: row.text, | ||
| ...(row.source === undefined ? {} : { source: row.source }), | ||
| }; | ||
| } | ||
| function requiredSessionId(value) { | ||
| if (value !== undefined) { | ||
| return value; | ||
| } | ||
| throw new Error('History cursor does not contain a message or session id'); | ||
| } |
@@ -1,3 +0,3 @@ | ||
| import type { SearchRow } from './db'; | ||
| import type { SyncResult } from './sidecar'; | ||
| import type { SearchRow } from './db.js'; | ||
| import type { SyncResult } from './sidecar.js'; | ||
| export interface HistorySearchResult { | ||
@@ -11,2 +11,3 @@ readonly cursor: string; | ||
| readonly score?: number; | ||
| readonly source?: SearchRow['source']; | ||
| readonly text: string; | ||
@@ -13,0 +14,0 @@ } |
@@ -1,2 +0,2 @@ | ||
| import { encodeCursor } from './cursor'; | ||
| import { encodeCursor } from './cursor.js'; | ||
| const SYNC_NOTICE_THRESHOLD_MS = 5_000; | ||
@@ -68,2 +68,3 @@ const MAX_RESULTS_PER_SESSION = 2; | ||
| ...(row.score === undefined ? {} : { score: Number(row.score.toFixed(4)) }), | ||
| ...(row.source === undefined ? {} : { source: row.source }), | ||
| time: new Date(row.timeCreated).toISOString(), | ||
@@ -70,0 +71,0 @@ text: makeSnippet(row.text), |
@@ -1,3 +0,3 @@ | ||
| import type { IndexSourceRow, SearchOptions, SearchRow } from './db'; | ||
| import type { EmbeddingProvider } from './embedding'; | ||
| import type { IndexSourceRow, SearchOptions, SearchRow } from './db.js'; | ||
| import type { EmbeddingProvider } from './embedding.js'; | ||
| export interface SyncResult { | ||
@@ -4,0 +4,0 @@ readonly elapsedMs: number; |
@@ -1,7 +0,7 @@ | ||
| import { Database } from 'bun:sqlite'; | ||
| import { createHash, randomUUID } from 'node:crypto'; | ||
| import { mkdirSync } from 'node:fs'; | ||
| import { dirname } from 'node:path'; | ||
| import { loadConfig } from './config'; | ||
| import { LexicalIndex } from './lexical-index'; | ||
| import { loadConfig } from './config.js'; | ||
| import { LexicalIndex } from './lexical-index.js'; | ||
| import { Database } from './sqlite.js'; | ||
| const INDEX_SCHEMA_VERSION = '2'; | ||
@@ -109,4 +109,4 @@ const SYNC_OVERLAP_MS = 30 * 60 * 1000; | ||
| } | ||
| // Bun's `Database.transaction(fn)` returns a callable that re-enters via | ||
| // begin/commit. We use it inside LexicalIndex so an external sync is enough. | ||
| // LexicalIndex owns nested transactions, so the semantic path keeps its sync | ||
| // steps explicit instead of wrapping the whole method in another transaction. | ||
| syncLexicalOnly(sourceRows, sourcePartIds) { | ||
@@ -113,0 +113,0 @@ if (!this.#acquireLock()) { |
+13
-11
| { | ||
| "name": "@josxa/opencode-recall", | ||
| "version": "0.3.0", | ||
| "version": "1.0.0", | ||
| "description": "OpenCode plugin that adds semantic + lexical recall over your local session history, with cursor-paginated ChatML transcript reads.", | ||
| "type": "module", | ||
| "packageManager": "pnpm@11.1.2", | ||
| "sideEffects": false, | ||
@@ -24,3 +25,3 @@ "main": "./dist/index.js", | ||
| "engines": { | ||
| "bun": ">=1.0.0" | ||
| "node": ">=22.12.0" | ||
| }, | ||
@@ -49,7 +50,7 @@ "keywords": [ | ||
| "scripts": { | ||
| "clean": "rm -rf dist", | ||
| "build": "bun run clean && tsc -p tsconfig.build.json", | ||
| "test": "bun test", | ||
| "eval:real-history": "bun scripts/evaluate-real-history.ts", | ||
| "eval:embeddings": "bun scripts/evaluate-embedding-models.ts", | ||
| "clean": "node -e \"import('node:fs').then(({ rmSync }) => rmSync('dist', { recursive: true, force: true }))\"", | ||
| "build": "pnpm run clean && tsc -p tsconfig.build.json", | ||
| "test": "vitest run", | ||
| "eval:real-history": "tsx scripts/evaluate-real-history.ts", | ||
| "eval:embeddings": "tsx scripts/evaluate-embedding-models.ts", | ||
| "typecheck": "tsgo --noEmit", | ||
@@ -60,3 +61,3 @@ "lint": "biome check --error-on-warnings .", | ||
| "prepare": "husky", | ||
| "prepublishOnly": "bun run build" | ||
| "prepublishOnly": "pnpm run build" | ||
| }, | ||
@@ -72,10 +73,11 @@ "files": [ | ||
| "devDependencies": { | ||
| "@biomejs/biome": "latest", | ||
| "@biomejs/biome": "2.4.15", | ||
| "@opencode-ai/plugin": "latest", | ||
| "@types/bun": "latest", | ||
| "@types/node": "latest", | ||
| "@typescript/native-preview": "latest", | ||
| "husky": "latest", | ||
| "typescript": "latest" | ||
| "tsx": "latest", | ||
| "typescript": "latest", | ||
| "vitest": "latest" | ||
| } | ||
| } |
+7
-7
@@ -74,3 +74,3 @@ # @josxa/opencode-recall | ||
| ```sh | ||
| bun add -d @josxa/opencode-recall | ||
| pnpm add -D @josxa/opencode-recall | ||
| ``` | ||
@@ -165,3 +165,3 @@ | ||
| Run `bun run eval:embeddings` to compare installed embedding models against the local regression cases in [`docs/real-history-regressions.md`](./docs/real-history-regressions.md). | ||
| Run `pnpm run eval:embeddings` to compare installed embedding models against the local regression cases in [`docs/real-history-regressions.md`](./docs/real-history-regressions.md). | ||
@@ -256,7 +256,7 @@ ## Tool reference | ||
| ```sh | ||
| bun install | ||
| bun run ai:check # biome + tsgo type-check | ||
| bun test # deterministic ranking + cursor tests | ||
| bun run eval:real-history # regression suite against your local opencode.db | ||
| bun run build # emits dist/ | ||
| pnpm install | ||
| pnpm run ai:check # biome + tsgo type-check | ||
| pnpm test # deterministic ranking + cursor tests | ||
| pnpm run eval:real-history # regression suite against your local opencode.db | ||
| pnpm run build # emits dist/ | ||
| ``` | ||
@@ -263,0 +263,0 @@ |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
150789
16.52%47
42.42%3455
20.76%0
-100%8
14.29%14
40%6
50%