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
10 changes: 10 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ No process runs between events: the handler wakes, executes to its next await, p
and the other provider namespaces remain follow-up work; see
[the generator notes](../packages/surface/src/helpers/README.md).

The initial local memory slice supports `recall` and `why` in authored flows,
with no journal step for either read. Script scope is stable across runs of
the same flow file and name; reads cannot widen it to another flow. The
existing `memory: { script: true }` header enables an eager reachability
check; direct `.memory` use also triggers it. Aliased access is checked at
call time. The local Node SDK and an existing readable SQLite DB are required
(`AI_HIST_DB` overrides `defaultDbPath()`); JSONL fallback is disabled.
`learn` and `memory: { agent: true }` refuse pending the journal-backed write
and identity-scoped agent follow-ups. CLI-only operation is also deferred.

4. **`{{prev}}` / return-value chaining.** Output flows downward implicitly; naming steps is for reaching back, not bookkeeping.
5. **Headers are optional escalation.** identity, memory, budget, tools appear only when used. The empty header is the common case. [Budget headers and spend](BUDGET.md) specifies parsing, prices, journal attribution, and admission limits.
6. **Agent definitions escalate by composition** — and a reusable agent *is* a flow:
Expand Down
90 changes: 55 additions & 35 deletions packages/sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@relayfile/relay-helpers": "0.4.11",
"@relayflows/surface": "2.0.8",
"@types/js-yaml": "^4.0.9",
"ai-hist": "0.4.1",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"js-yaml": "^5.4.1",
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/authored-flow-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type AuthoredFlowExecutionErrorCode =
| 'duplicate_completion'
| 'journal_protocol_violation'
| 'missing_completion'
| 'memory_unreachable'
| 'operation_after_completion'
| 'operation_callback_failed'
| 'step_failed'
Expand Down
17 changes: 16 additions & 1 deletion packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { SlackCall } from './slack-writeback.js';
import { checkMcpHeader, McpPreflightError } from './cli/check-typescript.js';
import { buildMcpProxy, runMcpEffect } from './authored-mcp.js';
import { AuthoredBudget } from './authored-budget.js';
import { assertMemoryReachable, authoredMemory, scriptMemoryScope } from './authored-memory.js';
import { authoredWorkerRunner } from './authored-worker-step.js';
import { readSuccessfulOutput, isSurfaceRunCompletionReason } from './authored-step-output.js';
import {
Expand Down Expand Up @@ -135,7 +136,7 @@ export async function executeAuthoredFlow<Input = undefined>(
...(options.onWait !== undefined ? { onWait: options.onWait } : {}),
};
const definition = getDefinition<Input>(handle);
const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget');
const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory');
if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['slack', 'mcp'].includes(key))) headerFields.push('tools');
if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile');
const helperPreflight = checkSlackHelpers(definition);
Expand All @@ -151,6 +152,15 @@ export async function executeAuthoredFlow<Input = undefined>(
if (!checkedMcp.report.ok) throw new McpPreflightError(checkedMcp.report);

const budget = new AuthoredBudget(definition.header.budget);
if (definition.header.memory?.agent === true) {
throw new AuthoredFlowExecutionError('unsupported_header', 'memory.agent requires the follow-up identity-scoped agent memory adapter');
}
// Direct member use is checked before any body effects; aliases are checked
// by the helper itself, without executing the body during discovery.
if (definition.header.memory !== undefined || /\.memory\b/.test(String(definition.body))) {
await assertMemoryReachable();
}
Comment thread
cursor[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory preflight regex misclassifies flows

Medium Severity

The eager memory probe decides reachability by testing /\.memory\b/ against String(definition.body). Function source includes comments and string literals, so a mention of .memory that is not a call still demands a readable SQLite database and can refuse the flow with memory_unreachable before the body runs.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9fc682e. Configure here.


const journalSteps: AuthoredFlowJournalStep[] = [];
const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
const lifecycle = new AuthoredFlowLifecycle();
Expand Down Expand Up @@ -231,6 +241,11 @@ export async function executeAuthoredFlow<Input = undefined>(
lifecycle,
));
}),
memory: authoredMemory(
scriptMemoryScope(flowPath, definition.name),
() => assertOperationAllowed('memory', definition.name, requestedCompletion),
definition.header.memory?.script !== false,
),
run(command) {
assertOperationAllowed('run', definition.name, requestedCompletion);
const id = `run-${nextStep++}`;
Expand Down
55 changes: 55 additions & 0 deletions packages/sdk/src/authored-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { MemoryHelper } from '@relayflows/surface';
import { createHash } from 'node:crypto';
import { access, stat } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
import { preflightMemory } from './preflight.js';

/** Stable across runs; disjoint from other flow files and other named flows. */
export function scriptMemoryScope(flowPath: string, name: string): string {
const absolute = resolve(flowPath);
const key = createHash('sha256').update(JSON.stringify([absolute, name])).digest('hex');
return join(dirname(absolute), '.relayflows', 'memory', 'scripts', key);
}

export async function probeScriptMemory(): Promise<void> {
const { defaultDbPath, openAiHist } = await import('ai-hist');
const path = defaultDbPath();
await access(path, constants.R_OK);
if (!(await stat(path)).isFile()) throw new Error('memory database must be a file');
const reader = await openAiHist({ dbPath: path, fallback: 'error' });
try { reader.search('', { limit: 1 }); } finally { reader.close(); }
}

export async function assertMemoryReachable(): Promise<void> {
const refusal = await preflightMemory(probeScriptMemory);
if (refusal) throw new AuthoredFlowExecutionError('memory_unreachable', refusal.message);
}

export function authoredMemory(
scope: string,
assertOpen: () => void,
enabled: boolean,
): MemoryHelper {
async function read<T>(action: (reader: import('ai-hist').AiHist) => T): Promise<T> {
assertOpen();
if (!enabled) throw new AuthoredFlowExecutionError('unsupported_header', 'f.memory requires script memory; memory.script is false');
await assertMemoryReachable();
assertOpen();
const { openAiHist } = await import('ai-hist');
const reader = await openAiHist({ projectScope: scope, fallback: 'error' });
try { return action(reader); } finally { reader.close(); }
}
return {
recall: (query, options) => read(reader => reader.search(query, { ...options, project: scope })),
why: task => read(reader => {
const entry = reader.whyForTask(task);
return entry?.projectId === scope ? [entry] : [];
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why() drops in-scope matches

High Severity

why asks whyForTask for a single best trajectory, then keeps it only when projectId equals the script scope. A better out-of-scope hit is discarded instead of falling back to the best in-scope trajectory, so f.memory.why can return an empty array even when this flow has a match.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1476610. Configure here.

async learn() {
assertOpen();
throw new AuthoredFlowExecutionError('unsupported_verb', 'f.memory.learn requires the follow-up journal-backed trajectory writer');
},
};
}
1 change: 1 addition & 0 deletions packages/sdk/src/failure-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PREFLIGHT_ENVIRONMENT_FAILURE_KINDS = [
'command_missing',
'model_unavailable',
'model_unknown',
'memory_unreachable',
'no_executor',
'probe_failed',
] as const;
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,19 @@ export function preflightHelpers(
: [];
return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics };
}

/** Authored memory probes run before invoking the body or contacting the journal. */
export async function preflightMemory(
probe: () => Promise<void>,
): Promise<PreflightRefusal | undefined> {
try {
await probe();
return undefined;
} catch {
return {
severity: 'refusal',
kind: 'memory_unreachable',
message: 'Script memory requires the ai-hist Node SDK and a readable SQLite database at AI_HIST_DB (or defaultDbPath()). Run ai-hist sync first.',
};
}
}
Loading
Loading