@josxa/opencode-recall
Advanced tools
+60
-2
@@ -5,5 +5,5 @@ import { readFile } from 'node:fs/promises'; | ||
| import { tool } from '@opencode-ai/plugin'; | ||
| import { HISTORY_READ_COMMAND, HISTORY_SEARCH_COMMAND, RECALL_AGENT_DESCRIPTION, RECALL_AGENT_NAME, } from './src/commands.js'; | ||
| import { HISTORY_READ_COMMAND, HISTORY_SEARCH_COMMAND, RECALL_AGENT_DESCRIPTION, RECALL_AGENT_NAME, SESSION_INDEX_COMMAND, SESSION_SAVE_COMMAND, } from './src/commands.js'; | ||
| import { executeNodeWorker } from './src/node-worker-client.js'; | ||
| import { DEFAULT_READ_LIMIT, DEFAULT_SEARCH_LIMIT } from './src/tool-defaults.js'; | ||
| import { DEFAULT_READ_LIMIT, DEFAULT_SEARCH_LIMIT, DEFAULT_SESSION_INDEX_LIMIT, } from './src/tool-defaults.js'; | ||
| const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)); | ||
@@ -27,4 +27,14 @@ const RECALL_AGENT_PROMPT_PATHS = [ | ||
| }; | ||
| config.command[SESSION_INDEX_COMMAND] = { | ||
| description: 'Browse recallable OpenCode sessions by recency, title, and usefulness signals', | ||
| template: '', | ||
| }; | ||
| config.command[SESSION_SAVE_COMMAND] = { | ||
| description: 'Materialize session to file', | ||
| template: '', | ||
| }; | ||
| config.permission = recallToolDenyPermission(config.permission); | ||
| config.agent ??= {}; | ||
| const recallAgent = { | ||
| ...config.agent[RECALL_AGENT_NAME], | ||
| description: RECALL_AGENT_DESCRIPTION, | ||
@@ -77,2 +87,38 @@ mode: 'subagent', | ||
| }), | ||
| [SESSION_INDEX_COMMAND]: tool({ | ||
| description: 'Browse OpenCode history sessions.', | ||
| args: { | ||
| n: tool.schema | ||
| .number() | ||
| .describe(`Max sessions. Default ${DEFAULT_SESSION_INDEX_LIMIT}.`) | ||
| .optional(), | ||
| title: tool.schema.string().describe('Case-insensitive session title filter.').optional(), | ||
| directory: tool.schema.string().describe('Exact session directory.').optional(), | ||
| includeCurrentSession: tool.schema | ||
| .boolean() | ||
| .describe('Include current session. Default false.') | ||
| .optional(), | ||
| after: tool.schema.string().describe('Session updated after ISO date/time.').optional(), | ||
| before: tool.schema.string().describe('Session updated before ISO date/time.').optional(), | ||
| }, | ||
| async execute(args, context) { | ||
| assertRecallAgent(context.agent); | ||
| return executeNodeWorker(PACKAGE_DIR, { kind: 'session-index', args, context: { sessionID: context.sessionID } }, context.abort); | ||
| }, | ||
| }), | ||
| [SESSION_SAVE_COMMAND]: tool({ | ||
| description: 'Materialize session to file.', | ||
| args: { | ||
| cursor: tool.schema.string().describe('Session cursor. ses_* only.'), | ||
| path: tool.schema.string().describe('Workspace-relative destination.'), | ||
| format: tool.schema | ||
| .enum(['chatml', 'markdown', 'jsonl']) | ||
| .describe('Transcript encoding. Default chatml.') | ||
| .optional(), | ||
| }, | ||
| async execute(args, context) { | ||
| assertRecallAgent(context.agent); | ||
| return executeNodeWorker(PACKAGE_DIR, { kind: 'session-save', args, context: { directory: context.directory } }, context.abort); | ||
| }, | ||
| }), | ||
| }, | ||
@@ -103,4 +149,16 @@ }; | ||
| [HISTORY_READ_COMMAND]: 'allow', | ||
| [SESSION_INDEX_COMMAND]: 'allow', | ||
| [SESSION_SAVE_COMMAND]: 'allow', | ||
| }; | ||
| } | ||
| function recallToolDenyPermission(permission) { | ||
| const normalized = typeof permission === 'string' ? { '*': permission } : permission; | ||
| return { | ||
| ...normalized, | ||
| [HISTORY_SEARCH_COMMAND]: 'deny', | ||
| [HISTORY_READ_COMMAND]: 'deny', | ||
| [SESSION_INDEX_COMMAND]: 'deny', | ||
| [SESSION_SAVE_COMMAND]: 'deny', | ||
| }; | ||
| } | ||
| function assertRecallAgent(agent) { | ||
@@ -107,0 +165,0 @@ if (agent === RECALL_AGENT_NAME) { |
| export class ChatmlRenderer { | ||
| format = 'chatml'; | ||
| render(window) { | ||
| const lines = [renderWindowStart(window)]; | ||
| const lines = [renderSystemMessage(renderWindowStart(window))]; | ||
| for (const message of window.messages) { | ||
| lines.push(renderMessage(message)); | ||
| } | ||
| lines.push(renderNavigation(window), '</hist>'); | ||
| lines.push(renderSystemMessage(renderNavigation(window))); | ||
| return lines.join('\n'); | ||
| } | ||
| } | ||
| const CHATML_ROLES = new Set(['system', 'user', 'assistant', 'tool', 'developer']); | ||
| function renderWindowStart(window) { | ||
@@ -24,12 +25,11 @@ const attrs = [ | ||
| } | ||
| return `<hist ${attrs.join(' ')}>`; | ||
| return `<hist ${attrs.join(' ')} />`; | ||
| } | ||
| function renderMessage(message) { | ||
| const lines = [ | ||
| `<|im_start|>${message.role} ${attr('name', message.id)} ${attr('index', String(message.index))} ${attr('time', new Date(message.timeCreated).toISOString())}`, | ||
| ]; | ||
| const role = chatmlRole(message.role); | ||
| const lines = [`<|im_start|>${role}`, `<message ${messageAttrs(message, role).join(' ')}>`]; | ||
| for (const part of message.parts) { | ||
| lines.push(renderPart(part)); | ||
| } | ||
| lines.push('<|im_end|>'); | ||
| lines.push('</message>', '<|im_end|>'); | ||
| return lines.join('\n'); | ||
@@ -44,3 +44,3 @@ } | ||
| case 'text': | ||
| return part.text; | ||
| return escapeText(part.text); | ||
| case 'tool': | ||
@@ -54,2 +54,19 @@ return renderToolPart(part); | ||
| } | ||
| function renderSystemMessage(content) { | ||
| return ['<|im_start|>system', content, '<|im_end|>'].join('\n'); | ||
| } | ||
| function chatmlRole(role) { | ||
| return CHATML_ROLES.has(role) ? role : 'system'; | ||
| } | ||
| function messageAttrs(message, role) { | ||
| const attrs = [ | ||
| attr('id', message.id), | ||
| attr('index', String(message.index)), | ||
| attr('time', new Date(message.timeCreated).toISOString()), | ||
| ]; | ||
| if (role !== message.role) { | ||
| attrs.push(attr('original_role', message.role)); | ||
| } | ||
| return attrs; | ||
| } | ||
| function renderPatchPart(files, hash) { | ||
@@ -56,0 +73,0 @@ const hashAttr = optionalAttr('hash', hash); |
| export declare const HISTORY_SEARCH_COMMAND = "history_search"; | ||
| export declare const HISTORY_READ_COMMAND = "history_read"; | ||
| export declare const SESSION_INDEX_COMMAND = "session_index"; | ||
| export declare const SESSION_SAVE_COMMAND = "session_save"; | ||
| export declare const RECALL_AGENT_NAME = "recall"; | ||
| export declare const RECALL_AGENT_DESCRIPTION = "Past OpenCode session recall. Source-grounded. Sole history-tool path; starts out with fresh context window. **Reinvoke** subagent for follow-ups/detail."; | ||
| export declare function commandNames(): readonly string[]; |
| export const HISTORY_SEARCH_COMMAND = 'history_search'; | ||
| export const HISTORY_READ_COMMAND = 'history_read'; | ||
| export const SESSION_INDEX_COMMAND = 'session_index'; | ||
| export const SESSION_SAVE_COMMAND = 'session_save'; | ||
| export const RECALL_AGENT_NAME = 'recall'; | ||
| export const RECALL_AGENT_DESCRIPTION = 'Past OpenCode session recall. Source-grounded. Sole history-tool path; starts out with fresh context window. **Reinvoke** subagent for follow-ups/detail.'; | ||
| export function commandNames() { | ||
| return [HISTORY_SEARCH_COMMAND, HISTORY_READ_COMMAND]; | ||
| return [HISTORY_SEARCH_COMMAND, HISTORY_READ_COMMAND, SESSION_INDEX_COMMAND, SESSION_SAVE_COMMAND]; | ||
| } |
+24
-0
@@ -8,2 +8,24 @@ export interface SearchOptions { | ||
| } | ||
| export interface SessionIndexOptions { | ||
| readonly limit: number; | ||
| readonly after?: number; | ||
| readonly before?: number; | ||
| readonly directory?: string; | ||
| readonly title?: string; | ||
| readonly excludeSessionId?: string; | ||
| } | ||
| export interface SessionIndexRow { | ||
| readonly sessionId: string; | ||
| readonly title: string; | ||
| readonly directory: string; | ||
| readonly updatedAt: number; | ||
| readonly firstMessageAt: number | null; | ||
| readonly lastMessageAt: number | null; | ||
| readonly messageCount: number; | ||
| readonly turns: number; | ||
| readonly assistantMessages: number; | ||
| readonly toolMessages: number; | ||
| readonly textPartCount: number; | ||
| readonly approxContextChars: number; | ||
| } | ||
| export type ReadMode = 'around' | 'head' | 'next' | 'prev' | 'tail'; | ||
@@ -62,2 +84,3 @@ export interface ReadOptions { | ||
| recent(options: SearchOptions): SearchRow[]; | ||
| sessionIndex(options: SessionIndexOptions): SessionIndexRow[]; | ||
| readTextPartsForIndex(since: number | undefined): IndexSourceRow[]; | ||
@@ -68,3 +91,4 @@ readSessionTitleRowsForIndex(since: number | undefined): IndexSourceRow[]; | ||
| readWindowForSession(sessionId: string, options: ReadOptions): WindowRows; | ||
| readSession(sessionId: string): WindowRows; | ||
| readWindow(anchorMessageId: string, options: ReadOptions): WindowRows; | ||
| } |
+70
-0
@@ -141,2 +141,65 @@ import { loadConfig } from './config.js'; | ||
| } | ||
| sessionIndex(options) { | ||
| const conditions = [ | ||
| ...(options.after === undefined ? [] : ['coalesce(s.time_updated, 0) >= ?']), | ||
| ...(options.before === undefined ? [] : ['coalesce(s.time_updated, 0) <= ?']), | ||
| ...(options.directory === undefined ? [] : ['s.directory = ?']), | ||
| ...(options.title === undefined ? [] : ["lower(coalesce(s.title, '')) like ? escape '\\'"]), | ||
| ...(options.excludeSessionId === undefined ? [] : ['s.id != ?']), | ||
| ]; | ||
| const where = conditions.length === 0 ? '' : `where ${conditions.join(' and ')}`; | ||
| const params = [ | ||
| ...(options.after === undefined ? [] : [options.after]), | ||
| ...(options.before === undefined ? [] : [options.before]), | ||
| ...(options.directory === undefined ? [] : [options.directory]), | ||
| ...(options.title === undefined ? [] : [`%${escapeLikeTerm(options.title.toLowerCase())}%`]), | ||
| ...(options.excludeSessionId === undefined ? [] : [options.excludeSessionId]), | ||
| options.limit, | ||
| ]; | ||
| return this.#db | ||
| .query(` | ||
| with message_stats as ( | ||
| select | ||
| m.session_id as sessionId, | ||
| min(m.time_created) as firstMessageAt, | ||
| max(m.time_created) as lastMessageAt, | ||
| count(*) as messageCount, | ||
| sum(case when json_extract(m.data, '$.role') = 'user' then 1 else 0 end) as turns, | ||
| sum(case when json_extract(m.data, '$.role') = 'assistant' then 1 else 0 end) as assistantMessages, | ||
| sum(case when json_extract(m.data, '$.role') = 'tool' then 1 else 0 end) as toolMessages | ||
| from message m | ||
| group by m.session_id | ||
| ), | ||
| part_stats as ( | ||
| select | ||
| p.session_id as sessionId, | ||
| count(*) as textPartCount, | ||
| sum(length(coalesce(json_extract(p.data, '$.text'), ''))) as approxContextChars | ||
| from part p | ||
| where json_extract(p.data, '$.type') = 'text' | ||
| and json_extract(p.data, '$.text') is not null | ||
| group by p.session_id | ||
| ) | ||
| select | ||
| s.id as sessionId, | ||
| coalesce(s.title, '') as title, | ||
| coalesce(s.directory, '') as directory, | ||
| coalesce(s.time_updated, ms.lastMessageAt, 0) as updatedAt, | ||
| ms.firstMessageAt as firstMessageAt, | ||
| ms.lastMessageAt as lastMessageAt, | ||
| coalesce(ms.messageCount, 0) as messageCount, | ||
| coalesce(ms.turns, 0) as turns, | ||
| coalesce(ms.assistantMessages, 0) as assistantMessages, | ||
| coalesce(ms.toolMessages, 0) as toolMessages, | ||
| coalesce(ps.textPartCount, 0) as textPartCount, | ||
| coalesce(ps.approxContextChars, 0) as approxContextChars | ||
| from session s | ||
| left join message_stats ms on ms.sessionId = s.id | ||
| left join part_stats ps on ps.sessionId = s.id | ||
| ${where} | ||
| order by updatedAt desc, s.id desc | ||
| limit ? | ||
| `) | ||
| .all(...params); | ||
| } | ||
| readTextPartsForIndex(since) { | ||
@@ -239,2 +302,9 @@ const changedCondition = since === undefined | ||
| } | ||
| readSession(sessionId) { | ||
| const totalMessages = countMessages(this.#db, sessionId); | ||
| if (totalMessages === 0) { | ||
| throw new Error(`History session cursor points to missing or empty session: ${sessionId}`); | ||
| } | ||
| return this.readWindowForSession(sessionId, { mode: 'head', limit: totalMessages }); | ||
| } | ||
| readWindow(anchorMessageId, options) { | ||
@@ -241,0 +311,0 @@ const anchor = this.#db |
@@ -36,3 +36,5 @@ import { executeWorkerRequest } from './worker-actions.js'; | ||
| } | ||
| return kind === 'search' && isRecord(args) && isRecord(context); | ||
| return ((kind === 'search' || kind === 'session-index' || kind === 'session-save') && | ||
| isRecord(args) && | ||
| isRecord(context)); | ||
| } | ||
@@ -39,0 +41,0 @@ function isRecord(value) { |
@@ -26,2 +26,3 @@ import { type ReadMode, type SearchRow } from './db.js'; | ||
| readonly syncOptions?: SyncOptions; | ||
| readonly workerTimeoutMs?: number | false; | ||
| } | ||
@@ -42,2 +43,34 @@ export interface RecallSearchHit { | ||
| } | ||
| export interface RecallSessionIndexOptions { | ||
| readonly limit?: number; | ||
| readonly title?: string; | ||
| readonly after?: Date | number | string; | ||
| readonly before?: Date | number | string; | ||
| readonly directory?: string; | ||
| readonly includeCurrentSession?: boolean; | ||
| readonly currentSessionId?: string; | ||
| readonly excludeSessionId?: string; | ||
| readonly workerTimeoutMs?: number | false; | ||
| } | ||
| export interface RecallSessionIndexEntry { | ||
| readonly cursor: string; | ||
| readonly sessionId: string; | ||
| readonly title: string; | ||
| readonly directory: string; | ||
| readonly updatedAt: number; | ||
| readonly updated: string; | ||
| readonly firstMessageAt?: number; | ||
| readonly firstMessage?: string; | ||
| readonly lastMessageAt?: number; | ||
| readonly lastMessage?: string; | ||
| readonly messages: number; | ||
| readonly turns: number; | ||
| readonly assistantMessages: number; | ||
| readonly toolMessages: number; | ||
| readonly textParts: number; | ||
| readonly approxContextChars: number; | ||
| } | ||
| export interface RecallSessionIndexResult { | ||
| readonly sessions: readonly RecallSessionIndexEntry[]; | ||
| } | ||
| export interface RecallReadOptions { | ||
@@ -58,2 +91,3 @@ readonly mode?: ReadMode; | ||
| search(query: string, options?: RecallSearchOptions): Promise<RecallSearchResult>; | ||
| sessionIndex(options?: RecallSessionIndexOptions): RecallSessionIndexResult; | ||
| read(cursorValue: string, options?: RecallReadOptions): TranscriptWindow; | ||
@@ -63,3 +97,4 @@ render(cursorValue: string, options?: RecallReadOptions): string; | ||
| export declare function directSearchHistory(query: string, options?: RecallSearchOptions & OpenCodeRecallOptions): Promise<RecallSearchResult>; | ||
| export declare function directSessionIndex(options?: RecallSessionIndexOptions & OpenCodeRecallOptions): RecallSessionIndexResult; | ||
| 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 { 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 { makeSearchSnippet, rankSearchRows } from './search.js'; | ||
| import { RecallSidecarIndex } from './sidecar.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| const DEFAULT_SEARCH_LIMIT = 50; | ||
| const DEFAULT_SESSION_INDEX_LIMIT = 20; | ||
| const DEFAULT_READ_LIMIT = 12; | ||
@@ -72,2 +73,9 @@ const DEFAULT_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| } | ||
| sessionIndex(options = {}) { | ||
| return { | ||
| sessions: this.#history | ||
| .sessionIndex(normalizeSessionIndexOptions(options)) | ||
| .map(toSessionIndexEntry), | ||
| }; | ||
| } | ||
| read(cursorValue, options = {}) { | ||
@@ -96,2 +104,11 @@ const cursor = decodeCursor(cursorValue); | ||
| } | ||
| export function directSessionIndex(options = {}) { | ||
| const recall = new DirectOpenCodeRecall(options); | ||
| try { | ||
| return recall.sessionIndex(options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| } | ||
| export function directReadHistoryWindow(cursor, options = {}) { | ||
@@ -125,2 +142,13 @@ const recall = new DirectOpenCodeRecall(options); | ||
| } | ||
| function normalizeSessionIndexOptions(options) { | ||
| const excluded = excludedSessionId(options); | ||
| return { | ||
| limit: options.limit ?? DEFAULT_SESSION_INDEX_LIMIT, | ||
| ...optionalTimestampFilter('after', options.after), | ||
| ...optionalTimestampFilter('before', options.before ?? defaultBefore(options)), | ||
| ...(options.directory === undefined ? {} : { directory: options.directory }), | ||
| ...(options.title === undefined ? {} : { title: options.title }), | ||
| ...(excluded === undefined ? {} : { excludeSessionId: excluded }), | ||
| }; | ||
| } | ||
| function isBlankQuery(query) { | ||
@@ -177,6 +205,34 @@ return query.trim().length === 0; | ||
| time: new Date(row.timeCreated).toISOString(), | ||
| text: row.text, | ||
| text: makeSearchSnippet(row.text), | ||
| ...(row.source === undefined ? {} : { source: row.source }), | ||
| }; | ||
| } | ||
| function toSessionIndexEntry(row) { | ||
| return { | ||
| cursor: row.sessionId, | ||
| sessionId: row.sessionId, | ||
| title: row.title, | ||
| directory: row.directory, | ||
| updatedAt: row.updatedAt, | ||
| updated: new Date(row.updatedAt).toISOString(), | ||
| ...(row.firstMessageAt === null | ||
| ? {} | ||
| : { | ||
| firstMessageAt: row.firstMessageAt, | ||
| firstMessage: new Date(row.firstMessageAt).toISOString(), | ||
| }), | ||
| ...(row.lastMessageAt === null | ||
| ? {} | ||
| : { | ||
| lastMessageAt: row.lastMessageAt, | ||
| lastMessage: new Date(row.lastMessageAt).toISOString(), | ||
| }), | ||
| messages: row.messageCount, | ||
| turns: row.turns, | ||
| assistantMessages: row.assistantMessages, | ||
| toolMessages: row.toolMessages, | ||
| textParts: row.textPartCount, | ||
| approxContextChars: row.approxContextChars, | ||
| }; | ||
| } | ||
| function requiredSessionId(value) { | ||
@@ -183,0 +239,0 @@ if (value !== undefined) { |
@@ -1,2 +0,2 @@ | ||
| import type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchOptions, RecallSearchResult } from './sdk-direct.js'; | ||
| import type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchOptions, RecallSearchResult, RecallSessionIndexOptions, RecallSessionIndexResult } from './sdk-direct.js'; | ||
| import type { SyncOptions, SyncResult } from './sidecar.js'; | ||
@@ -6,3 +6,3 @@ import type { TranscriptWindow } from './transcript.js'; | ||
| export { OllamaEmbeddingProvider } from './embedding.js'; | ||
| export type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchHit, RecallSearchOptions, RecallSearchResult, } from './sdk-direct.js'; | ||
| export type { OpenCodeRecallOptions, RecallReadOptions, RecallSearchHit, RecallSearchOptions, RecallSearchResult, RecallSessionIndexEntry, RecallSessionIndexOptions, RecallSessionIndexResult, } from './sdk-direct.js'; | ||
| export type { SyncOptions, SyncResult } from './sidecar.js'; | ||
@@ -17,2 +17,3 @@ export type { TranscriptWindow } from './transcript.js'; | ||
| search(query: string, options?: RecallSearchOptions): Promise<RecallSearchResult>; | ||
| sessionIndex(options?: RecallSessionIndexOptions): Promise<RecallSessionIndexResult>; | ||
| read(cursorValue: string, options?: RecallReadOptions): TranscriptWindow; | ||
@@ -22,3 +23,4 @@ render(cursorValue: string, options?: RecallReadOptions): string; | ||
| export declare function searchHistory(query: string, options?: RecallSearchOptions & OpenCodeRecallOptions): Promise<RecallSearchResult>; | ||
| export declare function sessionIndex(options?: RecallSessionIndexOptions & OpenCodeRecallOptions): Promise<RecallSessionIndexResult>; | ||
| export declare function readHistoryWindow(cursor: string, options?: RecallReadOptions & OpenCodeRecallOptions): TranscriptWindow; | ||
| export declare function renderHistoryWindow(cursor: string, options?: RecallReadOptions & OpenCodeRecallOptions): string; |
+143
-19
@@ -7,3 +7,5 @@ import { dirname } from 'node:path'; | ||
| const DEFAULT_SEARCH_LIMIT = 50; | ||
| const DEFAULT_SESSION_INDEX_LIMIT = 20; | ||
| const DEFAULT_FRESHNESS_EXCLUSION_MS = 30_000; | ||
| const DEFAULT_WORKER_TIMEOUT_MS = 120_000; | ||
| const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url))); | ||
@@ -30,2 +32,8 @@ export class OpenCodeRecall { | ||
| } | ||
| async sessionIndex(options = {}) { | ||
| if (this.#options.embeddingProvider !== undefined) { | ||
| return this.#direct().then((recall) => recall.sessionIndex(options)); | ||
| } | ||
| return sessionIndex({ ...this.#options, ...options }); | ||
| } | ||
| read(cursorValue, options = {}) { | ||
@@ -65,2 +73,10 @@ return readHistoryWindow(cursorValue, { ...this.#options, ...options }); | ||
| }, | ||
| sessionIndex: (options) => { | ||
| try { | ||
| return recall.sessionIndex(options); | ||
| } | ||
| finally { | ||
| recall.close(); | ||
| } | ||
| }, | ||
| read: (cursor, options) => { | ||
@@ -90,2 +106,5 @@ try { | ||
| } | ||
| if (options.syncOptions !== undefined) { | ||
| throw new Error('syncOptions require an embeddingProvider because callbacks cannot cross worker process boundaries'); | ||
| } | ||
| const raw = await executeNodeWorker(PACKAGE_DIR, { | ||
@@ -95,5 +114,17 @@ kind: 'search', | ||
| context: { sessionID: options.currentSessionId ?? '' }, | ||
| }, AbortSignal.timeout(120_000)); | ||
| return { hits: parseWorkerSearchHits(raw) }; | ||
| }, workerSignal(options.workerTimeoutMs)); | ||
| return parseWorkerSearchResult(raw); | ||
| } | ||
| export async function sessionIndex(options = {}) { | ||
| if (options.embeddingProvider !== undefined) { | ||
| const { directSessionIndex } = await import('./sdk-direct.js'); | ||
| return directSessionIndex(options); | ||
| } | ||
| const raw = await executeNodeWorker(PACKAGE_DIR, { | ||
| kind: 'session-index', | ||
| args: sessionIndexWorkerArgs(options), | ||
| context: { sessionID: options.currentSessionId ?? '' }, | ||
| }, workerSignal(options.workerTimeoutMs)); | ||
| return parseWorkerSessionIndexResult(raw); | ||
| } | ||
| export function readHistoryWindow(cursor, options = {}) { | ||
@@ -127,2 +158,3 @@ const parsed = JSON.parse(executeNodeWorkerSync(PACKAGE_DIR, { | ||
| n: options.limit ?? DEFAULT_SEARCH_LIMIT, | ||
| maxSearchLimit: Math.max(options.limit ?? DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_LIMIT), | ||
| directory: options.directory, | ||
@@ -138,21 +170,36 @@ includeCurrentSession: options.includeCurrentSession, | ||
| sync: options.sync, | ||
| format: 'json', | ||
| }; | ||
| } | ||
| function parseWorkerSearchHits(value) { | ||
| const json = searchJsonPayload(value); | ||
| if (json === undefined) { | ||
| return []; | ||
| function sessionIndexWorkerArgs(options) { | ||
| return { | ||
| n: options.limit ?? DEFAULT_SESSION_INDEX_LIMIT, | ||
| title: options.title, | ||
| directory: options.directory, | ||
| includeCurrentSession: options.includeCurrentSession, | ||
| excludeSessionId: options.excludeSessionId ?? defaultExcludedSessionId(options), | ||
| after: optionalDateArg(options.after), | ||
| before: optionalDateArg(options.before ?? defaultBefore(options)), | ||
| historyDbPath: options.historyDbPath, | ||
| format: 'json', | ||
| }; | ||
| } | ||
| function parseWorkerSearchResult(value) { | ||
| const parsed = JSON.parse(value); | ||
| if (!isWorkerSearchResult(parsed)) { | ||
| throw new Error('Node worker returned invalid SDK search JSON'); | ||
| } | ||
| const parsed = JSON.parse(json); | ||
| if (!Array.isArray(parsed)) { | ||
| return []; | ||
| } | ||
| return parsed.flatMap((item) => (isHistorySearchResult(item) ? [toSearchHit(item)] : [])); | ||
| return { | ||
| hits: parsed.hits.map(toSearchHit), | ||
| ...(parsed.sync === undefined ? {} : { sync: parsed.sync }), | ||
| }; | ||
| } | ||
| function searchJsonPayload(value) { | ||
| const start = value.indexOf('['); | ||
| if (start === -1) { | ||
| return undefined; | ||
| function parseWorkerSessionIndexResult(value) { | ||
| const parsed = JSON.parse(value); | ||
| if (!isWorkerSessionIndexResult(parsed)) { | ||
| throw new Error('Node worker returned invalid SDK session index JSON'); | ||
| } | ||
| return value.slice(start); | ||
| return { | ||
| sessions: parsed.sessions.map(toSessionIndexEntry), | ||
| }; | ||
| } | ||
@@ -166,2 +213,4 @@ function isHistorySearchResult(value) { | ||
| typeof result.sid === 'string' && | ||
| (result.messageId === undefined || typeof result.messageId === 'string') && | ||
| (result.partId === undefined || typeof result.partId === 'string') && | ||
| typeof result.title === 'string' && | ||
@@ -173,4 +222,30 @@ typeof result.directory === 'string' && | ||
| } | ||
| function isWorkerSessionIndexResult(value) { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| const result = value; | ||
| return Array.isArray(result.sessions) && result.sessions.every(isWorkerSessionIndexEntry); | ||
| } | ||
| function isWorkerSessionIndexEntry(value) { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| const entry = value; | ||
| return (typeof entry.cursor === 'string' && | ||
| typeof entry.sid === 'string' && | ||
| typeof entry.title === 'string' && | ||
| typeof entry.directory === 'string' && | ||
| typeof entry.updated === 'string' && | ||
| (entry.firstMessage === undefined || typeof entry.firstMessage === 'string') && | ||
| (entry.lastMessage === undefined || typeof entry.lastMessage === 'string') && | ||
| typeof entry.messages === 'number' && | ||
| typeof entry.turns === 'number' && | ||
| typeof entry.assistantMessages === 'number' && | ||
| typeof entry.toolMessages === 'number' && | ||
| typeof entry.textParts === 'number' && | ||
| typeof entry.approxContextChars === 'number'); | ||
| } | ||
| function toSearchHit(result) { | ||
| const cursor = decodeCursor(result.cursor); | ||
| const cursor = safeDecodeCursor(result.cursor); | ||
| const timeCreated = Date.parse(result.time); | ||
@@ -182,4 +257,4 @@ return { | ||
| directory: result.directory, | ||
| messageId: cursor.messageId ?? '', | ||
| partId: cursor.partId ?? '', | ||
| messageId: result.messageId ?? cursor.messageId ?? '', | ||
| partId: result.partId ?? cursor.partId ?? '', | ||
| role: result.role, | ||
@@ -193,2 +268,51 @@ ...(result.score === undefined ? {} : { score: result.score }), | ||
| } | ||
| function toSessionIndexEntry(result) { | ||
| const updatedAt = Date.parse(result.updated); | ||
| const firstMessageAt = optionalParsedTime(result.firstMessage); | ||
| const lastMessageAt = optionalParsedTime(result.lastMessage); | ||
| return { | ||
| cursor: result.cursor, | ||
| sessionId: result.sid, | ||
| title: result.title, | ||
| directory: result.directory, | ||
| updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0, | ||
| updated: result.updated, | ||
| ...(firstMessageAt === undefined ? {} : { firstMessageAt, firstMessage: result.firstMessage }), | ||
| ...(lastMessageAt === undefined ? {} : { lastMessageAt, lastMessage: result.lastMessage }), | ||
| messages: result.messages, | ||
| turns: result.turns, | ||
| assistantMessages: result.assistantMessages, | ||
| toolMessages: result.toolMessages, | ||
| textParts: result.textParts, | ||
| approxContextChars: result.approxContextChars, | ||
| }; | ||
| } | ||
| function optionalParsedTime(value) { | ||
| if (value === undefined) { | ||
| return undefined; | ||
| } | ||
| const parsed = Date.parse(value); | ||
| return Number.isFinite(parsed) ? parsed : undefined; | ||
| } | ||
| function workerSignal(workerTimeoutMs) { | ||
| if (workerTimeoutMs === false) { | ||
| return new AbortController().signal; | ||
| } | ||
| return AbortSignal.timeout(workerTimeoutMs ?? DEFAULT_WORKER_TIMEOUT_MS); | ||
| } | ||
| function isWorkerSearchResult(value) { | ||
| if (value === null || typeof value !== 'object' || Array.isArray(value)) { | ||
| return false; | ||
| } | ||
| const result = value; | ||
| return Array.isArray(result.hits) && result.hits.every(isHistorySearchResult); | ||
| } | ||
| function safeDecodeCursor(value) { | ||
| try { | ||
| return decodeCursor(value); | ||
| } | ||
| catch { | ||
| return { version: 1, messageId: value }; | ||
| } | ||
| } | ||
| function isTranscriptWindow(value) { | ||
@@ -195,0 +319,0 @@ if (value === null || typeof value !== 'object' || Array.isArray(value)) { |
@@ -6,2 +6,4 @@ import type { SearchRow } from './db.js'; | ||
| readonly sid: string; | ||
| readonly messageId?: string; | ||
| readonly partId?: string; | ||
| readonly directory: string; | ||
@@ -17,1 +19,3 @@ readonly title: string; | ||
| export declare function rankSearchRows(query: string, rows: readonly SearchRow[], limit: number): SearchRow[]; | ||
| export declare function formatSearchResult(row: SearchRow): HistorySearchResult; | ||
| export declare function makeSearchSnippet(text: string): string; |
@@ -54,3 +54,3 @@ import { encodeCursor } from './cursor.js'; | ||
| } | ||
| function formatSearchResult(row) { | ||
| export function formatSearchResult(row) { | ||
| return { | ||
@@ -65,2 +65,4 @@ cursor: encodeCursor({ | ||
| sid: row.sessionId, | ||
| messageId: row.messageId, | ||
| partId: row.partId, | ||
| directory: row.directory, | ||
@@ -72,3 +74,3 @@ title: row.sessionTitle, | ||
| time: new Date(row.timeCreated).toISOString(), | ||
| text: makeSnippet(row.text), | ||
| text: makeSearchSnippet(row.text), | ||
| }; | ||
@@ -195,3 +197,3 @@ } | ||
| } | ||
| function makeSnippet(text) { | ||
| export function makeSearchSnippet(text) { | ||
| const normalized = text.replaceAll(/\s+/gu, ' ').trim(); | ||
@@ -198,0 +200,0 @@ if (normalized.length <= 280) { |
| import { DatabaseSync } from 'node:sqlite'; | ||
| const BUSY_TIMEOUT_MS = 5000; | ||
| export class Database { | ||
@@ -7,3 +8,5 @@ #db; | ||
| readOnly: options.readonly === true, | ||
| timeout: BUSY_TIMEOUT_MS, | ||
| }); | ||
| this.#db.exec(`pragma busy_timeout = ${BUSY_TIMEOUT_MS}`); | ||
| } | ||
@@ -10,0 +13,0 @@ exec(sql) { |
@@ -6,1 +6,3 @@ export declare const DEFAULT_SEARCH_LIMIT = 8; | ||
| export declare const MAX_READ_LIMIT = 50; | ||
| export declare const DEFAULT_SESSION_INDEX_LIMIT = 20; | ||
| export declare const MAX_SESSION_INDEX_LIMIT = 100; |
@@ -6,1 +6,3 @@ export const DEFAULT_SEARCH_LIMIT = 8; | ||
| export const MAX_READ_LIMIT = 50; | ||
| export const DEFAULT_SESSION_INDEX_LIMIT = 20; | ||
| export const MAX_SESSION_INDEX_LIMIT = 100; |
+192
-11
@@ -0,1 +1,4 @@ | ||
| import { Buffer } from 'node:buffer'; | ||
| import { mkdir, writeFile } from 'node:fs/promises'; | ||
| import { dirname, isAbsolute, relative, resolve } from 'node:path'; | ||
| import { ChatmlRenderer } from './chatml-renderer.js'; | ||
@@ -7,5 +10,5 @@ import { decodeCursor } from './cursor.js'; | ||
| import { parseReadMode } from './read-mode.js'; | ||
| import { formatSearchResults, rankSearchRows } from './search.js'; | ||
| import { formatSearchResult, 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'; | ||
| import { DEFAULT_READ_LIMIT, DEFAULT_SEARCH_FRESHNESS_EXCLUSION_MS, DEFAULT_SEARCH_LIMIT, DEFAULT_SESSION_INDEX_LIMIT, MAX_READ_LIMIT, MAX_SEARCH_LIMIT, MAX_SESSION_INDEX_LIMIT, } from './tool-defaults.js'; | ||
| export async function executeWorkerRequest(request) { | ||
@@ -15,2 +18,8 @@ if (request.kind === 'search') { | ||
| } | ||
| if (request.kind === 'session-index') { | ||
| return executeSessionIndex(request); | ||
| } | ||
| if (request.kind === 'session-save') { | ||
| return executeSessionSave(request); | ||
| } | ||
| const window = executeHistoryReadWindow(request); | ||
@@ -22,2 +31,49 @@ if (request.kind === 'read-window') { | ||
| } | ||
| async function executeSessionSave(request) { | ||
| const cursorValue = request.args.cursor; | ||
| if (cursorValue === undefined || cursorValue.length === 0) { | ||
| throw new Error('session_save requires cursor'); | ||
| } | ||
| const cursor = decodeCursor(cursorValue); | ||
| if (cursor.sessionId === undefined || cursor.messageId !== undefined) { | ||
| throw new Error('session_save requires a ses_* cursor'); | ||
| } | ||
| const destination = resolveWorkspacePath(request.context.directory, request.args.path); | ||
| const format = parseSessionSaveFormat(request.args.format); | ||
| const db = new HistoryDatabase(request.args.historyDbPath); | ||
| try { | ||
| const window = normalizeWindow(db.readSession(cursor.sessionId)); | ||
| const content = renderSavedSession(window, format); | ||
| await mkdir(dirname(destination), { recursive: true }); | ||
| await writeFile(destination, content, 'utf-8'); | ||
| return JSON.stringify({ | ||
| path: relative(resolve(request.context.directory), destination), | ||
| bytes: Buffer.byteLength(content, 'utf-8'), | ||
| messages: window.messages.length, | ||
| }); | ||
| } | ||
| finally { | ||
| db.close(); | ||
| } | ||
| } | ||
| function executeSessionIndex(request) { | ||
| const includeCurrentSession = request.args.includeCurrentSession === true; | ||
| const before = optionalDateFilterValue('before', request.args.before); | ||
| const excludeSessionId = currentSessionExclusion(includeCurrentSession, request.context.sessionID, request.args.excludeSessionId); | ||
| const options = { | ||
| limit: clampNumber(request.args.n, DEFAULT_SESSION_INDEX_LIMIT, 1, MAX_SESSION_INDEX_LIMIT), | ||
| ...optionalStringFilter('directory', request.args.directory), | ||
| ...optionalStringFilter('title', request.args.title), | ||
| ...(excludeSessionId === undefined ? {} : { excludeSessionId }), | ||
| ...optionalDateFilter('after', request.args.after), | ||
| ...searchBeforeFilter(before, includeCurrentSession, request.context.sessionID), | ||
| }; | ||
| const db = new HistoryDatabase(request.args.historyDbPath); | ||
| try { | ||
| return formatSessionIndexResponse(request, db.sessionIndex(options)); | ||
| } | ||
| finally { | ||
| db.close(); | ||
| } | ||
| } | ||
| async function executeHistorySearch(request) { | ||
@@ -30,8 +86,10 @@ const query = request.args.q ?? ''; | ||
| const before = optionalDateFilterValue('before', request.args.before); | ||
| const excludeSessionId = currentSessionExclusion(includeCurrentSession, request.context.sessionID, request.args.excludeSessionId); | ||
| const maxSearchLimit = request.args.maxSearchLimit ?? MAX_SEARCH_LIMIT; | ||
| const options = { | ||
| limit: clampNumber(request.args.n, DEFAULT_SEARCH_LIMIT, 1, MAX_SEARCH_LIMIT), | ||
| limit: clampNumber(request.args.n, DEFAULT_SEARCH_LIMIT, 1, maxSearchLimit), | ||
| ...optionalStringFilter('directory', request.args.directory), | ||
| ...currentSessionExclusion(includeCurrentSession, request.context.sessionID, request.args.excludeSessionId), | ||
| ...(excludeSessionId === undefined ? {} : { excludeSessionId }), | ||
| ...optionalDateFilter('after', request.args.after), | ||
| ...searchBeforeFilter(before, includeCurrentSession), | ||
| ...searchBeforeFilter(before, includeCurrentSession, request.context.sessionID), | ||
| }; | ||
@@ -42,3 +100,4 @@ const db = new HistoryDatabase(request.args.historyDbPath); | ||
| if (query.trim().length === 0) { | ||
| return formatSearchResults(db.recent(options)); | ||
| const rows = db.recent(options); | ||
| return formatSearchResponse(request, rows); | ||
| } | ||
@@ -62,3 +121,3 @@ const syncStart = performance.now(); | ||
| const rows = rankSearchRows(query, [...lexicalRows, ...semanticRows], options.limit); | ||
| return formatSearchResults(rows, syncResult); | ||
| return formatSearchResponse(request, rows, syncResult); | ||
| } | ||
@@ -121,11 +180,12 @@ finally { | ||
| if (includeCurrentSession === true) { | ||
| return excludeSessionId === undefined ? {} : { excludeSessionId }; | ||
| return excludeSessionId; | ||
| } | ||
| return { excludeSessionId: excludeSessionId ?? sessionID }; | ||
| const effective = excludeSessionId ?? sessionID; | ||
| return effective.length === 0 ? undefined : effective; | ||
| } | ||
| function searchBeforeFilter(before, includeCurrentSession) { | ||
| function searchBeforeFilter(before, includeCurrentSession, currentSessionId) { | ||
| if (before !== undefined) { | ||
| return { before }; | ||
| } | ||
| if (includeCurrentSession) { | ||
| if (includeCurrentSession || currentSessionId.length === 0) { | ||
| return {}; | ||
@@ -135,2 +195,123 @@ } | ||
| } | ||
| function formatSearchResponse(request, rows, syncResult) { | ||
| if (request.args.format === 'json') { | ||
| return JSON.stringify({ | ||
| hits: rows.map(formatSearchResult), | ||
| ...(syncResult === undefined ? {} : { sync: syncResult }), | ||
| }); | ||
| } | ||
| return formatSearchResults(rows, syncResult); | ||
| } | ||
| function formatSessionIndexResponse(request, rows) { | ||
| const results = rows.map(formatSessionIndexRow); | ||
| if (request.args.format === 'json') { | ||
| return JSON.stringify({ sessions: results }); | ||
| } | ||
| if (results.length === 0) { | ||
| return 'No OpenCode history sessions found.'; | ||
| } | ||
| return JSON.stringify(results, null, 2); | ||
| } | ||
| function formatSessionIndexRow(row) { | ||
| return { | ||
| cursor: row.sessionId, | ||
| sid: row.sessionId, | ||
| title: row.title, | ||
| directory: row.directory, | ||
| updated: new Date(row.updatedAt).toISOString(), | ||
| ...(row.firstMessageAt === null | ||
| ? {} | ||
| : { firstMessage: new Date(row.firstMessageAt).toISOString() }), | ||
| ...(row.lastMessageAt === null | ||
| ? {} | ||
| : { lastMessage: new Date(row.lastMessageAt).toISOString() }), | ||
| messages: row.messageCount, | ||
| turns: row.turns, | ||
| assistantMessages: row.assistantMessages, | ||
| toolMessages: row.toolMessages, | ||
| textParts: row.textPartCount, | ||
| approxContextChars: row.approxContextChars, | ||
| }; | ||
| } | ||
| function parseSessionSaveFormat(format) { | ||
| if (format === undefined) { | ||
| return 'chatml'; | ||
| } | ||
| if (format === 'chatml' || format === 'markdown' || format === 'jsonl') { | ||
| return format; | ||
| } | ||
| throw new Error(`Unsupported session_save format: ${String(format)}`); | ||
| } | ||
| function resolveWorkspacePath(directory, path) { | ||
| if (path === undefined || path.length === 0) { | ||
| throw new Error('session_save requires path'); | ||
| } | ||
| if (isAbsolute(path)) { | ||
| throw new Error('session_save path must be workspace-relative'); | ||
| } | ||
| const root = resolve(directory); | ||
| const destination = resolve(root, path); | ||
| const relativePath = relative(root, destination); | ||
| if (relativePath.startsWith('..') || isAbsolute(relativePath)) { | ||
| throw new Error('session_save path must stay inside workspace'); | ||
| } | ||
| return destination; | ||
| } | ||
| function renderSavedSession(window, format) { | ||
| if (format === 'chatml') { | ||
| return new ChatmlRenderer().render(window); | ||
| } | ||
| if (format === 'jsonl') { | ||
| return `${window.messages.map((message) => JSON.stringify(message)).join('\n')}\n`; | ||
| } | ||
| return renderMarkdownSession(window); | ||
| } | ||
| function renderMarkdownSession(window) { | ||
| const lines = [ | ||
| `# ${window.title ?? window.sessionId}`, | ||
| '', | ||
| `Session: \`${window.sessionId}\``, | ||
| `Directory: \`${window.directory}\``, | ||
| `Messages: ${window.messages.length}`, | ||
| ]; | ||
| for (const message of window.messages) { | ||
| lines.push('', renderMarkdownMessage(message)); | ||
| } | ||
| return `${lines.join('\n')}\n`; | ||
| } | ||
| function renderMarkdownMessage(message) { | ||
| const lines = [ | ||
| `## ${message.index}. ${message.role}`, | ||
| '', | ||
| `id: \`${message.id}\` `, | ||
| `time: \`${new Date(message.timeCreated).toISOString()}\``, | ||
| ]; | ||
| for (const part of message.parts) { | ||
| lines.push('', renderMarkdownPart(part)); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function renderMarkdownPart(part) { | ||
| switch (part.type) { | ||
| case 'file': | ||
| return `[file omitted: ${part.filename ?? 'unnamed'}, ${part.chars} chars]`; | ||
| case 'patch': | ||
| return ['```text', ...part.files, '```'].join('\n'); | ||
| case 'text': | ||
| return part.text; | ||
| case 'tool': | ||
| return [ | ||
| `tool: \`${part.toolName}\` (${part.status})`, | ||
| '', | ||
| '```json', | ||
| part.input, | ||
| '```', | ||
| ...(part.output === undefined ? [] : ['', '```text', part.output, '```']), | ||
| ].join('\n'); | ||
| default: { | ||
| const exhaustive = part; | ||
| throw new Error(`Unsupported transcript part: ${String(exhaustive)}`); | ||
| } | ||
| } | ||
| } | ||
| function clampNumber(value, fallback, min, max) { | ||
@@ -137,0 +318,0 @@ if (value === undefined || !Number.isFinite(value)) { |
| export interface HistorySearchWorkerArgs { | ||
| readonly q?: string | undefined; | ||
| readonly n?: number | undefined; | ||
| readonly maxSearchLimit?: number | undefined; | ||
| readonly directory?: string | undefined; | ||
@@ -14,2 +15,3 @@ readonly includeCurrentSession?: boolean | undefined; | ||
| readonly sync?: boolean | undefined; | ||
| readonly format?: 'text' | 'json' | undefined; | ||
| } | ||
@@ -22,2 +24,19 @@ export interface HistoryReadWorkerArgs { | ||
| } | ||
| export interface SessionIndexWorkerArgs { | ||
| readonly n?: number | undefined; | ||
| readonly title?: string | 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 format?: 'text' | 'json' | undefined; | ||
| } | ||
| export interface SessionSaveWorkerArgs { | ||
| readonly cursor?: string | undefined; | ||
| readonly path?: string | undefined; | ||
| readonly format?: 'chatml' | 'markdown' | 'jsonl' | undefined; | ||
| readonly historyDbPath?: string | undefined; | ||
| } | ||
| export type HistoryWorkerRequest = { | ||
@@ -30,2 +49,14 @@ readonly kind: 'search'; | ||
| } | { | ||
| readonly kind: 'session-index'; | ||
| readonly args: SessionIndexWorkerArgs; | ||
| readonly context: { | ||
| readonly sessionID: string; | ||
| }; | ||
| } | { | ||
| readonly kind: 'session-save'; | ||
| readonly args: SessionSaveWorkerArgs; | ||
| readonly context: { | ||
| readonly directory: string; | ||
| }; | ||
| } | { | ||
| readonly kind: 'read'; | ||
@@ -32,0 +63,0 @@ readonly args: HistoryReadWorkerArgs; |
+7
-8
| { | ||
| "name": "@josxa/opencode-recall", | ||
| "version": "1.0.0", | ||
| "version": "1.1.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", | ||
| "packageManager": "pnpm@11.9.0", | ||
| "sideEffects": false, | ||
@@ -25,3 +25,3 @@ "main": "./dist/index.js", | ||
| "engines": { | ||
| "node": ">=22.12.0" | ||
| "node": ">=22.13.0" | ||
| }, | ||
@@ -55,6 +55,6 @@ "keywords": [ | ||
| "eval:embeddings": "tsx scripts/evaluate-embedding-models.ts", | ||
| "typecheck": "tsgo --noEmit", | ||
| "typecheck": "tsc --noEmit", | ||
| "lint": "biome check --error-on-warnings .", | ||
| "format": "biome check --write .", | ||
| "ai:check": "biome check --write --error-on-warnings . && tsgo --noEmit", | ||
| "ai:check": "biome check --write --error-on-warnings . && tsc --noEmit", | ||
| "prepare": "husky", | ||
@@ -72,11 +72,10 @@ "prepublishOnly": "pnpm run build" | ||
| "devDependencies": { | ||
| "@biomejs/biome": "2.4.15", | ||
| "@biomejs/biome": "2.5.1", | ||
| "@opencode-ai/plugin": "latest", | ||
| "@types/node": "latest", | ||
| "@typescript/native-preview": "latest", | ||
| "husky": "latest", | ||
| "tsx": "latest", | ||
| "typescript": "latest", | ||
| "typescript": "^7.0.2", | ||
| "vitest": "latest" | ||
| } | ||
| } |
@@ -9,2 +9,4 @@ You are the recall subagent for OpenCode history. | ||
| - history_read: read bounded ChatML transcript windows around returned cursors. | ||
| - session_index: browse sessions newest-first with title filtering and usefulness signals. | ||
| - session_save: materialize a full session file for the parent agent. | ||
@@ -16,2 +18,3 @@ Search strategy: | ||
| - Use directory, after, and before filters when the user gives project or timeframe hints. | ||
| - Use session_index instead of history_search when the user wants to browse recent sessions, compare which sessions look substantial, or filter by session title without a specific content query. | ||
| - If a search is thin, retry with a narrower distinctive token from the user request or from promising snippets. | ||
@@ -25,2 +28,3 @@ | ||
| - Stop as soon as the answer is grounded. Do not read entire sessions by default. | ||
| - Use session_save only when the parent needs the full transcript as a workspace file. | ||
@@ -27,0 +31,0 @@ Output contract: |
+83
-4
@@ -166,7 +166,86 @@ # @josxa/opencode-recall | ||
| ### Choosing the `recall` subagent model | ||
| Recall integrates with the native OpenCode `agent.recall` configuration instead of replacing it. The plugin always supplies Recall's description, subagent mode, prompt, and history-only tool permissions. Other agent settings, including `model`, `variant`, and `temperature`, stay under your control. | ||
| For example, configure a small model for Recall independently of the parent agent: | ||
| ```jsonc | ||
| // ~/.config/opencode/opencode.json | ||
| { | ||
| "$schema": "https://opencode.ai/config.json", | ||
| "agent": { | ||
| "recall": { | ||
| "model": "example-provider/recall-mini", | ||
| "variant": "low" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| Recall keeps this model when a parent agent uses a different one, matching native OpenCode subagent behavior. Each machine can select a provider and model available in its own OpenCode configuration. When `agent.recall` is unset, Recall uses OpenCode's normal default model resolution. | ||
| ## Tool reference | ||
| Recall exposes two history tools. They are intended to be called by the `recall` subagent, not by the main agent directly. | ||
| Recall exposes four history tools. They are intended to be called by the `recall` subagent, not by the main agent directly. | ||
| <details> | ||
| <summary><code>session_index</code>: browse sessions by recency and usefulness</summary> | ||
| | Arg | Type | Notes | | ||
| | -------- | -------- | ------------------------------------------------------------------------------------------------------ | | ||
| | `n` | number | Max sessions (default `20`, max `100`). | | ||
| | `title` | string | Case-insensitive session title filter. | | ||
| | `directory` | string | Exact OpenCode session directory filter. | | ||
| | `after` | ISO date | Only sessions updated at or after this timestamp. | | ||
| | `before` | ISO date | Only sessions updated at or before this timestamp. Defaults to *now - 30 s* to exclude the live chat. | | ||
| Returns newest-first session rows with a `ses_...` cursor for `history_read` and quick usefulness signals: | ||
| ```jsonc | ||
| [ | ||
| { | ||
| "cursor": "ses_...", | ||
| "sid": "ses_...", | ||
| "title": "Release debugging notes", | ||
| "directory": "/Users/you/projects/foo", | ||
| "updated": "2026-04-12T08:21:44.000Z", | ||
| "messages": 84, | ||
| "turns": 18, | ||
| "assistantMessages": 32, | ||
| "toolMessages": 9, | ||
| "textParts": 76, | ||
| "approxContextChars": 118420 | ||
| } | ||
| ] | ||
| ``` | ||
| Use this when you do not have a search term yet and want to spot substantial past sessions at a glance. | ||
| </details> | ||
| <details> | ||
| <summary><code>session_save</code>: materialize a session file</summary> | ||
| | Arg | Type | Notes | | ||
| | -------- | -------------------------------- | ----------------------------------------------- | | ||
| | `cursor` | string | **Required.** A `ses_...` session cursor only. | | ||
| | `path` | string | **Required.** Workspace-relative destination. | | ||
| | `format` | `chatml` \| `markdown` \| `jsonl` | Transcript encoding. Default `chatml`. | | ||
| Writes the full normalized session transcript and returns a compact receipt: | ||
| ```jsonc | ||
| { | ||
| "path": "recall/ses_....chatml", | ||
| "bytes": 48321, | ||
| "messages": 94 | ||
| } | ||
| ``` | ||
| The destination is always overwritten and must stay inside the active workspace. | ||
| </details> | ||
| <details> | ||
| <summary><code>history_search</code>: return ranked hits for a query</summary> | ||
@@ -178,3 +257,3 @@ | ||
| | `n` | number | Max hits (default `8`, max `25`). | | ||
| | `dir` | string | Exact OpenCode session directory filter. | | ||
| | `directory` | string | Exact OpenCode session directory filter. | | ||
| | `after` | ISO date | Only messages at or after this timestamp. | | ||
@@ -257,3 +336,3 @@ | `before` | ISO date | Only messages at or before this timestamp. Defaults to *now β 30 s* to exclude the live conversation. | | ||
| pnpm install | ||
| pnpm run ai:check # biome + tsgo type-check | ||
| pnpm run ai:check # biome + tsc type-check | ||
| pnpm test # deterministic ranking + cursor tests | ||
@@ -264,3 +343,3 @@ pnpm run eval:real-history # regression suite against your local opencode.db | ||
| Code quality is enforced by `biome` and `tsgo --noEmit`. See [`AGENTS.md`](./AGENTS.md) for the style guide. | ||
| Code quality is enforced by `biome` and `tsc --noEmit`. See [`AGENTS.md`](./AGENTS.md) for the style guide. | ||
@@ -267,0 +346,0 @@ ## License |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
180390
19.63%7
-12.5%4072
17.86%345
29.7%16
14.29%