Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent-skills/do-recall-dump/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Dump the current conversation session into the Recall SQLite database, making al

Run this at the end of every session or when you want to persist the current conversation mid-session.

**In omp:** call `recall_dump` with the title. See the [omp dump contract](https://github.com/edheltzel/Recall/blob/main/docs/OMP_INTEGRATION.md#explicit-dump-recall_dump-tool) for confirmation and session-source rules.

## Usage

```bash
Expand Down
10 changes: 8 additions & 2 deletions docs/OMP_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,15 @@ This is turn-completion capture, not crash recovery. An interrupted turn before

Capture sends only user and assistant text, at most 25 MiB per payload, and bounds its child process to 30 seconds. Tool output, images, and other non-message entries are not sent and do not count toward the limit. Oversize, malformed, cancelled, or failed captures warn without blocking the agent or publishing a partial branch.

## Explicit dump: `recall_dump` tool

The extension also registers a `recall_dump` tool (title, optional project, tags, `continues`, `skip_fabric`). It reads the live branch and session id from omp, so it dumps this conversation on any profile or branch, and sends it to the package-local `dist/index.js dump --stdin`. The tool asks you to confirm in the omp UI before writing anything and refuses in headless sessions. Without `--stdin`, `recall dump` run from a shell inside omp (`OMPCODE=1`) refuses and points to the tool: native transcript discovery cannot identify the live omp conversation.

Dump payloads are limited to 25 MiB, and the CLI child has a five-minute timeout. `skip_fabric` defaults to false; set it to true for a basic summary. Shared message scrubbing and default project attribution follow the [Session Dump contract](cli-reference.md#session-dump).

## Skills and MCP remain separate

`recall install` still links the nine canonical `do-recall-*` skills into `~/.omp/agent/skills/` when omp is detected. It does not activate this extension. The native package does not duplicate those skills, register MCP, or inject session-start memory. Existing manual MCP configuration remains untouched.
`recall install` still links the nine canonical `do-recall-*` skills into `~/.omp/agent/skills/` when omp is detected. It does not activate this extension. The native package does not duplicate those skills, register MCP, or inject session-start memory; `recall_dump` is its only tool. Existing manual MCP configuration remains untouched.

## Verify and remove

Expand All @@ -56,7 +62,7 @@ This verifies the packed local-link integration in headless text mode. It does n

Restart omp after uninstalling. Saved Recall memory remains. `recall uninstall` removes installer-owned skills, not the separately managed omp plugin; uninstall the plugin through omp before removing Recall's runtime.

If capture warns, check `bun` is available to omp, run `bun dist/index.js init` from the linked checkout, and check `RECALL_DB_PATH` permissions. A branch whose user and assistant text alone exceeds 25 MiB needs a new session; restarting alone does not shrink it.
If capture warns, check `bun` is available to omp, run `bun dist/index.js init` from the linked checkout, and check `RECALL_DB_PATH` permissions. An omp process started before the extension was refreshed keeps the old code until it restarts: older versions measured the whole raw branch and warned `payload exceeds 25MiB` on long tool-heavy sessions. If the filtered user/assistant payload still exceeds the capture limit, start a new session. The limit counts serialized JSON bytes, including escaping and metadata.

## Native documentation

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,4 @@ Skill names use the canonical `do-recall-*` namespace. Claude and Codex load the

The Codex plugin adapters are generated with `bun run build:codex-plugin` and packaged with the native plugin bundle documented in [Codex Integration](CODEX_INTEGRATION.md). Claude copies are generated with `bun run build:claude-plugin`; see [Claude Integration](CLAUDE_INTEGRATION.md).

If installer-linked skills are missing after an update, re-run `./packaging/install.sh` to relink them. These links are omp's supported skill path; its native extension handles capture only. Claude, Codex, and Pi prefer their native attachments.
If installer-linked skills are missing after an update, re-run `./packaging/install.sh` to relink them. See [omp Integration](OMP_INTEGRATION.md#skills-and-mcp-remain-separate) for its skill and extension ownership. Claude, Codex, and Pi prefer their native attachments.
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ bun -e 'import { describeHarnessSeams } from "./src/api.ts"; console.log(JSON.st
|------|-------------------|---------------|
| **start** | `recall start` renders L0/L1 via `hooks/lib/session-start-context.ts`. `--format cursor` wraps `{ additional_context }`. | `registerStartFormat(id, wrap)` — in-process, for `runStart({ format: id })`. The public CLI still accepts only `markdown` \| `cursor`. |
| **drop** | Write markdown into `MEMORY/<host>-sessions/`. Batch extract scans `*-sessions` generically. | None. The directory name **is** the extension point. `parseMarkdownDrop` / `markdownDropDirName` are the helpers. |
| **capture** | Cursor: `catalogCursorSessions()` (catalog only, no SQLite insert). Dump: `discoverCurrentSession()`. Ambient harness text: `recall capture` ([Capture adapter](CAPTURE_ADAPTER.md)). Hidden `host-hook` is deprecated for ambient writes; Codex `SessionStart` still uses it for injection only. | `registerSessionSource(adapter)` — in-process, consulted by `discoverCurrentSession()`. Cannot replace builtin `SESSION_SOURCES` ids. |
| **capture** | Cursor: `catalogCursorSessions()` (catalog only, no SQLite insert). Dump: `discoverCurrentSession()` ([omp restriction](OMP_INTEGRATION.md#explicit-dump-recall_dump-tool)). Ambient harness text: `recall capture` ([Capture adapter](CAPTURE_ADAPTER.md)). Hidden `host-hook` is deprecated for ambient writes; Codex `SessionStart` still uses it for injection only. | `registerSessionSource(adapter)` — in-process, consulted by `discoverCurrentSession()`. Cannot replace builtin `SESSION_SOURCES` ids. |
| **inject** | Cursor snippets under `templates/cursor/` call unqualified `recall start --format cursor`. Claude/Codex call the same assembler through their own hooks. | `mergeCursorHooksJson` is the Cursor hooks.json helper. Do not add a marketplace plugin. |

MCP (`recall-mcp`, server name `recall-memory`) and Agent Skills (`agent-skills/`) are already cross-host. Point at [MCP Tools](mcp-tools.md) and [Agent Skills](agent-skills.md); do not fork those catalogs.
Expand Down
6 changes: 6 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ recall dump "Session Title" # Import session + capture LoA entry

Combines `recall import` and Curated LoA capture in one step. Run this at the end of every session to persist the conversation and run the `fabric` Extractor. The title should describe what was accomplished in the session. `--skip-fabric` writes a basic summary instead.

Without `--stdin`, Recall discovers native transcripts in Claude Code, OpenCode, then Pi order, followed by registered session sources. For omp's live-session route and discovery restriction, see the [`recall_dump` contract](OMP_INTEGRATION.md#explicit-dump-recall_dump-tool).

`--stdin` reads up to 25 MiB of contract 1 JSON for host extensions that hold the live transcript: `contract: 1`, `harness`, `session_id`, optional `cwd`/`project`, and `messages` with user/assistant `role`, nonblank text `content`, and optional `timestamp`. Malformed input is rejected before writes. `-p` overrides the payload's project; otherwise Recall uses its `project`, then the origin repository name or directory basename from `cwd`, then `harness` if `cwd` is absent.

Both CLI dumps and MCP `memory_dump` scrub each message's content with the shared write-safety guard before snapshot matching, storage, and summary or extraction. Known credential patterns become visible `[REDACTED:<kind>]` markers, invisible/bidi controls are removed, and incoming messages left blank are dropped. This does not detect every secret; follow the [credential-handling rule](mcp-tools.md#memory_add) before dumping sensitive conversations.

### Library of Alexandria (LoA)

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ memory_dump({

`memory_dump` is an explicit operation, not lifecycle auto-capture. MCP has no portable event contract for session stop, session start, or pre-compaction.

Message scrubbing follows the shared [Session Dump contract](cli-reference.md#session-dump). Inside omp, use the native [`recall_dump` tool](OMP_INTEGRATION.md#explicit-dump-recall_dump-tool) for the live branch.

When `session_id` already belongs to lifecycle capture, a dump from the same source is supplemental: Recall preserves lifecycle-owned rows and their automatic summary. A conflicting source is rejected rather than taking over the session.

---
Expand Down
181 changes: 149 additions & 32 deletions hosts/omp/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { z } from 'zod';

const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url));
const CLI_PATH = join(PACKAGE_ROOT, 'dist', 'index.js');
Expand All @@ -18,26 +19,56 @@ export interface OmpSessionStopEvent {
export interface OmpExtensionContext {
cwd: string;
hasUI?: boolean;
ui?: { notify: (message: string, type?: 'info' | 'warning' | 'error') => void };
ui?: {
notify: (message: string, type?: 'info' | 'warning' | 'error') => void;
confirm?: (title: string, message: string, options?: { signal?: AbortSignal }) => Promise<boolean>;
};
sessionManager: {
getSessionId: () => string;
getBranch: (fromId?: string) => unknown[];
};
}

export interface OmpToolResult {
content: { type: 'text'; text: string }[];
}

export interface OmpExtensionAPI {
on(
event: 'session_stop',
handler: (event: OmpSessionStopEvent, ctx: OmpExtensionContext) => unknown,
): void;
registerTool?(tool: {
name: string;
label: string;
description: string;
parameters: unknown;
execute(
toolCallId: string,
params: RecallDumpParams,
signal: AbortSignal | undefined,
onUpdate: unknown,
ctx: OmpExtensionContext,
): Promise<OmpToolResult>;
}): void;
zod?: typeof z;
logger?: { warn: (message: string) => void };
}

export interface RecallDumpParams {
title: string;
project?: string;
tags?: string;
continues?: number;
skip_fabric?: boolean;
}

export type RunBoundedChild = (
file: string,
args: readonly string[],
stdin: string,
signal: AbortSignal,
onOutput?: (chunk: string) => void,
) => Promise<number | null>;

function warn(ctx: OmpExtensionContext, pi: OmpExtensionAPI | undefined, message: string): void {
Expand All @@ -61,20 +92,25 @@ function warn(ctx: OmpExtensionContext, pi: OmpExtensionAPI | undefined, message
}
}

/** Spawn a child, write stdin, honor abort (SIGTERM then SIGKILL). */
/** Spawn a child, write stdin, honor abort (SIGTERM then SIGKILL). `onOutput` opts into stdout+stderr. */
export function runBoundedChild(
file: string,
args: readonly string[],
stdin: string,
signal: AbortSignal,
onOutput?: (chunk: string) => void,
): Promise<number | null> {
if (signal.aborted) return Promise.resolve(null);

const { promise, resolve, reject } = Promise.withResolvers<number | null>();
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;

const child = spawn(file, [...args], {
stdio: ['pipe', 'ignore', 'ignore'],
});
const output = onOutput ? 'pipe' : 'ignore';
const child = spawn(file, [...args], { stdio: ['pipe', output, output] });
for (const stream of [child.stdout, child.stderr]) {
stream?.setEncoding('utf8').on('data', (chunk: string) => onOutput?.(chunk));
}

const onAbort = () => {
try {
Expand Down Expand Up @@ -108,8 +144,7 @@ export function runBoundedChild(
});
child.stdin?.end(stdin, 'utf8');

if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
signal.addEventListener('abort', onAbort, { once: true });

return promise;
}
Expand Down Expand Up @@ -142,12 +177,14 @@ function ompMessageText(content: unknown): string {
return parts.join('\n');
}

/** Ordered user/assistant text. Malformed branches throw so capture is not partial. */
function ompBranchText(entries: unknown): string {
export interface OmpMessage { role: 'user' | 'assistant'; content: string; timestamp?: string }

/** Ordered user/assistant messages. Malformed branches throw so capture/dump is not partial. */
export function ompBranchMessages(entries: unknown): OmpMessage[] {
if (!Array.isArray(entries)) {
throw new Error('omp lifecycle capture requires an entries array');
}
const parts: string[] = [];
const messages: OmpMessage[] = [];
const seenIds = new Set<string>();
for (const entry of entries) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
Expand Down Expand Up @@ -178,9 +215,13 @@ function ompBranchText(entries: unknown): string {
}
const content = ompMessageText(message.content);
if (!content.trim()) continue;
parts.push(content);
messages.push({
role: message.role,
content,
timestamp: typeof row.timestamp === 'string' && row.timestamp ? row.timestamp : undefined,
});
}
return parts.join('\n\n');
return messages;
}

export async function captureOmpSessionStop(
Expand All @@ -202,7 +243,7 @@ export async function captureOmpSessionStop(
return;
}

const text = ompBranchText(entries);
const text = ompBranchMessages(entries).map(message => message.content).join('\n\n');
if (!text.trim()) return;

const payload = JSON.stringify({
Expand All @@ -218,30 +259,106 @@ export async function captureOmpSessionStop(
return;
}

const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), CHILD_TIMEOUT_MS);
timer.unref();
const signal = event.signal
? AbortSignal.any([event.signal, timeout.signal])
: timeout.signal;

try {
const code = await runChild('bun', [CLI_PATH, 'capture'], payload, signal);
if (code === 0) return;
warn(
ctx,
pi,
signal.aborted ? 'Recall omp capture cancelled' : 'Recall omp capture failed',
);
} finally {
clearTimeout(timer);
timeout.abort();
}
const { code, aborted } = await runCli(runChild, ['capture'], payload, event.signal, CHILD_TIMEOUT_MS);
if (code === 0) return;
warn(ctx, pi, aborted ? 'Recall omp capture cancelled' : 'Recall omp capture failed');
} catch {
warn(ctx, pi, 'Recall omp capture failed');
}
}

/** Run the package-local CLI under the caller's signal plus a hard timeout. */
async function runCli(
runChild: RunBoundedChild,
args: readonly string[],
payload: string,
outer: AbortSignal | undefined,
timeoutMs: number,
onOutput?: (chunk: string) => void,
): Promise<{ code: number | null; aborted: boolean }> {
if (outer?.aborted) return { code: null, aborted: true };

const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), timeoutMs);
timer.unref();
const signal = outer ? AbortSignal.any([outer, timeout.signal]) : timeout.signal;
try {
const code = await runChild('bun', [CLI_PATH, ...args], payload, signal, onOutput);
return { code, aborted: signal.aborted };
} finally {
clearTimeout(timer);
timeout.abort();
}
}

/** The fabric Extractor can take minutes on a long session. */
const DUMP_TIMEOUT_MS = 5 * 60_000;

/**
* `recall_dump` tool: the live branch and session id come from omp itself, so the dump
* matches this conversation on every profile and branch. `recall dump` without this
* input refuses inside omp rather than guessing from files. Requires user confirmation.
*/
export async function dumpOmpSession(
params: RecallDumpParams,
ctx: OmpExtensionContext,
signal?: AbortSignal,
runChild: RunBoundedChild = runBoundedChild,
): Promise<OmpToolResult> {
const reply = (text: string): OmpToolResult => ({ content: [{ type: 'text', text }] });
try {
if (signal?.aborted) return reply('Recall dump cancelled');
// Dumping is always the user's call (agent-skills/AGENTS.md): the model can request
// it, but nothing is written without a yes in the omp UI. Headless sessions refuse.
if (!ctx.hasUI || !ctx.ui?.confirm) return reply('Recall dump needs the interactive omp UI to confirm; nothing was saved');
const approved = await ctx.ui.confirm('Save this conversation to Recall?', `recall dump "${params.title}"`, { signal });
if (signal?.aborted) return reply('Recall dump cancelled');
if (!approved) return reply('Recall dump declined; nothing was saved');
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) return reply('Recall dump failed: omp did not report a session id');
const messages = ompBranchMessages(ctx.sessionManager.getBranch());
if (messages.length === 0) return reply('Recall dump skipped: no user or assistant text in this session yet');
const payload = JSON.stringify({ contract: 1, harness: 'omp', session_id: sessionId, cwd: ctx.cwd, messages });
if (Buffer.byteLength(payload, 'utf8') > MAX_OMP_STDIN_BYTES) {
return reply('Recall dump skipped: conversation text exceeds 25MiB');
}

const args = ['dump', '--stdin'];
if (params.project) args.push('-p', params.project);
if (params.tags) args.push('-t', params.tags);
if (params.continues !== undefined) args.push('-c', String(params.continues));
if (params.skip_fabric) args.push('--skip-fabric');
// `--` keeps a title like `--help` from being parsed as an option.
args.push('--', params.title);

let output = '';
const { code, aborted } = await runCli(runChild, args, payload, signal, DUMP_TIMEOUT_MS, chunk => {
output += chunk;
});
if (code === 0) return reply(output.trim() || 'Recall dump saved');
if (aborted) return reply('Recall dump cancelled');
return reply(`Recall dump failed (exit ${code})${output.trim() ? `\n${output.trim()}` : ''}`);
} catch (error) {
if (signal?.aborted) return reply('Recall dump cancelled');
return reply(`Recall dump failed: ${error instanceof Error ? error.message : String(error)}`);
}
}

export default function recallOmpExtension(pi: OmpExtensionAPI): void {
pi.on('session_stop', (event, ctx) => captureOmpSessionStop(event, ctx, pi));
const z = pi.zod;
if (!pi.registerTool || !z) return;
pi.registerTool({
name: 'recall_dump',
label: 'Recall dump',
description: 'Save this omp conversation (its live branch) to Recall and write a Curated LoA entry. Use this for do-recall-dump inside omp.',
parameters: z.object({
title: z.string().min(1).describe('Descriptive title for this session'),
project: z.string().optional().describe('Project name tag'),
tags: z.string().optional().describe('Comma-separated tags'),
continues: z.number().int().positive().optional().describe('Previous LoA entry id to chain from'),
skip_fabric: z.boolean().optional().describe('Write a basic summary instead of running the fabric Extractor'),
}),
execute: (_toolCallId, params, signal, _onUpdate, ctx) => dumpOmpSession(params, ctx, signal),
});
}
2 changes: 2 additions & 0 deletions hosts/plugins/recall-claude/skills/do-recall-dump/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Dump the current conversation session into the Recall SQLite database, making al

Run this at the end of every session or when you want to persist the current conversation mid-session.

**In omp:** call `recall_dump` with the title. See the [omp dump contract](https://github.com/edheltzel/Recall/blob/main/docs/OMP_INTEGRATION.md#explicit-dump-recall_dump-tool) for confirmation and session-source rules.

## Usage

```bash
Expand Down
2 changes: 2 additions & 0 deletions hosts/plugins/recall/skills/do-recall-dump/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Dump the current conversation session into the Recall SQLite database, making al

Run this at the end of every session or when you want to persist the current conversation mid-session.

**In omp:** call `recall_dump` with the title. See the [omp dump contract](https://github.com/edheltzel/Recall/blob/main/docs/OMP_INTEGRATION.md#explicit-dump-recall_dump-tool) for confirmation and session-source rules.

## Usage

```bash
Expand Down
Loading
Loading