🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP β†’
Sign In

@josxa/opencode-recall

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@josxa/opencode-recall - npm Package Compare versions

Comparing version
1.0.0
to
1.1.0
+60
-2
dist/index.js

@@ -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) {

+25
-8
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];
}

@@ -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;
}

@@ -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;

@@ -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;

@@ -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;

{
"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:

@@ -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