diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 7f928f3..c3b85c6 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -104,6 +104,9 @@ export type { // Recipes Pack — durable named playbooks the agent learns once + replays. export { createRecipesPlugin, + LocalFileRecipeBackend, + RemoteRecipeBackend, + RemoteRecipeError, RecipeStore, RECIPES_TOOLS, } from '../plugins/recipes' @@ -113,6 +116,10 @@ export type { RecipeStep, RecipeParameter, RecipeVerification, + RecipeBackend, + RecipeBackendDescription, + LocalFileRecipeBackendConfig, + RemoteRecipeBackendConfig, } from '../plugins/recipes' // Memory Pack — hierarchical persistent memory for AI agents. Opt-in; @@ -120,6 +127,9 @@ export type { // amnesia is the dominant UX limitation. export { createMemoryPlugin, + LocalFileMemoryBackend, + RemoteMemoryBackend, + RemoteMemoryError, MemoryStore, MEMORY_TOOLS, } from '../plugins/memory' @@ -129,6 +139,11 @@ export type { MemoryScope, MemoryScopeType, MemorySearchQuery, + MemoryBackend, + MemoryBackendDescription, + MemorySetInput, + LocalFileMemoryBackendConfig, + RemoteMemoryBackendConfig, } from '../plugins/memory' // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the diff --git a/src/plugins/memory/backend.ts b/src/plugins/memory/backend.ts new file mode 100644 index 0000000..dc52386 --- /dev/null +++ b/src/plugins/memory/backend.ts @@ -0,0 +1,42 @@ +/** + * Memory backend abstraction. + * + * The same plugin works against any backend that satisfies this + * interface. Two ship in v0: + * + * - LocalFileMemoryBackend — JSON file on disk (default). + * - RemoteMemoryBackend — HTTP client; talks to a ThinkFleet + * service (on-prem or cloud). + * + * The desktop app picks one at construction time based on user/org + * config. The same agentmark binary supports both wirings — no + * conditional code paths inside the plugin or its tool handlers. + */ +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface MemorySetInput { + key: string + value: unknown + scope?: MemoryScope + tags?: string[] + ttlSeconds?: number +} + +export interface MemoryBackend { + set(input: MemorySetInput): Promise + get(input: { key: string; scopes?: MemoryScope[] }): Promise + deleteById(recordId: string): Promise + deleteByKey(key: string, scope: MemoryScope): Promise + search(query: MemorySearchQuery): Promise + list(scope?: MemoryScope, prefix?: string, limit?: number): Promise + clear(): Promise + /** Backend-defined metadata for inclusion in agentmark_list_sessions / + * agentmark_capabilities output. The `kind` field is the only one + * every backend must set ("local-file", "remote-http", "in-memory"). */ + describe(): Promise +} + +export interface MemoryBackendDescription { + kind: string + [k: string]: unknown +} diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts index 10ac705..2775c76 100644 --- a/src/plugins/memory/index.ts +++ b/src/plugins/memory/index.ts @@ -12,22 +12,32 @@ * right place. agentmark_memory_get can walk the scope hierarchy in * one call. */ -import { MemoryStore } from './store' +import { LocalFileMemoryBackend } from './store' import { MEMORY_TOOLS } from './tool-defs' +import type { MemoryBackend } from './backend' import type { MemoryScope } from './types' import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' export interface MemoryPluginConfig { - /** Override the on-disk store path. */ + /** + * Storage backend. Pass a `RemoteMemoryBackend` to point at an + * on-prem or cloud ThinkFleet memory service. Defaults to a + * `LocalFileMemoryBackend` at ~/.thinkfleet/agentmark/memory.json. + */ + backend?: MemoryBackend + /** Local-file backend convenience: override the on-disk store path. + * Ignored when `backend` is supplied. */ storePath?: string - /** Soft cap on total records before LRU eviction. Default: 10000. */ + /** Local-file backend convenience: soft LRU cap. Ignored when + * `backend` is supplied. Default: 10000. */ maxRecords?: number - /** Default scope to apply when callers omit one. */ + /** Default scope to apply when callers omit one. Ignored when + * `backend` is supplied (configure on the backend instead). */ defaultScope?: MemoryScope } export function createMemoryPlugin(config: MemoryPluginConfig = {}): AgentMarkPlugin { - const store = new MemoryStore({ + const store: MemoryBackend = config.backend ?? new LocalFileMemoryBackend({ path: config.storePath, maxRecords: config.maxRecords, defaultScope: config.defaultScope, @@ -100,17 +110,29 @@ export function createMemoryPlugin(config: MemoryPluginConfig = {}): AgentMarkPl }, } + // describeSessions is synchronous; pre-compute the static parts here + // and let `describe()` (async) feed into agentmark_capabilities via + // its own code path. The static description is enough for routine + // diagnostics. + const isLocalFile = store instanceof LocalFileMemoryBackend + return { name: 'memory', version: '0.1.0', tools: MEMORY_TOOLS, handlers, describeSessions: () => ({ - memory: { - store_path: store.filePath, - max_records: store.maxRecords, - default_scope: store.defaultScope, - }, + memory: isLocalFile + ? { + kind: 'local-file', + store_path: (store as LocalFileMemoryBackend).filePath, + max_records: (store as LocalFileMemoryBackend).maxRecords, + default_scope: (store as LocalFileMemoryBackend).defaultScope, + } + : { + kind: 'custom', + backend_class: store.constructor.name, + }, }), } } @@ -145,7 +167,11 @@ function optionalStringArray(v: unknown): string[] | undefined { return v as string[] } -export { MemoryStore } from './store' +export { LocalFileMemoryBackend, MemoryStore } from './store' +export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store' +export { RemoteMemoryBackend, RemoteMemoryError } from './remote-backend' +export type { RemoteMemoryBackendConfig } from './remote-backend' +export type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' export { MEMORY_TOOLS } from './tool-defs' export type { MemoryRecord, diff --git a/src/plugins/memory/remote-backend.ts b/src/plugins/memory/remote-backend.ts new file mode 100644 index 0000000..a389592 --- /dev/null +++ b/src/plugins/memory/remote-backend.ts @@ -0,0 +1,170 @@ +/** + * Remote (HTTP) memory backend. + * + * Hits a ThinkFleet memory service over HTTPS. Same agentmark binary + * works against on-prem deployments (internal URL, internal auth) and + * cloud deployments (api.thinkfleet.ai) — only the `baseUrl` + `token` + * differ. + * + * Auth: Bearer token. Optional `X-Workspace-Id` header scopes calls to + * a specific team/org so a single user can be a member of multiple + * workspaces. + * + * Endpoint contract (v1 proposal — subject to change before the + * service ships): + * + * POST /v1/memory/records { key, value, scope, tags?, ttl_seconds? } → MemoryRecord + * POST /v1/memory/records/get { key, scopes? } → MemoryRecord | null + * DELETE /v1/memory/records/:record_id → { deleted: boolean } + * DELETE /v1/memory/records { key, scope } → { deleted: boolean } + * POST /v1/memory/search MemorySearchQuery → MemoryRecord[] + * GET /v1/memory/records?scope_type=&scope_id=&prefix=&limit= → MemoryRecord[] + * DELETE /v1/memory/records/all → { cleared: true } + * GET /v1/memory/describe → MemoryBackendDescription + * + * Errors: non-2xx responses are surfaced as RemoteMemoryError with the + * status code + parsed JSON body (when JSON). Auth-related 401s also + * include the workspace header for easier debugging. + */ +import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface RemoteMemoryBackendConfig { + /** Base URL of the memory service (no trailing slash). */ + baseUrl: string + /** Bearer token. Required for all non-anonymous deployments. */ + token?: string + /** Optional workspace / team id sent as `X-Workspace-Id`. */ + workspaceId?: string + /** Injectable fetch (defaults to globalThis.fetch). Used by tests. */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15_000. */ + timeoutMs?: number +} + +export class RemoteMemoryError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'RemoteMemoryError' + } +} + +export class RemoteMemoryBackend implements MemoryBackend { + readonly baseUrl: string + readonly workspaceId?: string + private readonly token?: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: RemoteMemoryBackendConfig) { + if (!config.baseUrl) throw new Error('RemoteMemoryBackend: baseUrl is required.') + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.token = config.token + this.workspaceId = config.workspaceId + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async set(input: MemorySetInput): Promise { + return await this.request('POST', '/v1/memory/records', { + key: input.key, + value: input.value, + scope: input.scope ?? { type: 'platform' }, + tags: input.tags, + ttl_seconds: input.ttlSeconds, + }) + } + + async get(input: { key: string; scopes?: MemoryScope[] }): Promise { + const result = await this.request('POST', '/v1/memory/records/get', input) + return result ?? null + } + + async deleteById(recordId: string): Promise { + const r = await this.request<{ deleted: boolean }>( + 'DELETE', + `/v1/memory/records/${encodeURIComponent(recordId)}`, + ) + return r.deleted === true + } + + async deleteByKey(key: string, scope: MemoryScope): Promise { + const r = await this.request<{ deleted: boolean }>('DELETE', '/v1/memory/records', { key, scope }) + return r.deleted === true + } + + async search(query: MemorySearchQuery): Promise { + return await this.request('POST', '/v1/memory/search', query) + } + + async list(scope?: MemoryScope, prefix?: string, limit?: number): Promise { + const params = new URLSearchParams() + if (scope?.type) params.set('scope_type', scope.type) + if (scope?.id) params.set('scope_id', scope.id) + if (prefix) params.set('prefix', prefix) + if (typeof limit === 'number') params.set('limit', String(limit)) + const qs = params.toString() + return await this.request( + 'GET', + `/v1/memory/records${qs ? '?' + qs : ''}`, + ) + } + + async clear(): Promise { + await this.request<{ cleared: true }>('DELETE', '/v1/memory/records/all') + } + + async describe(): Promise { + const remote = await this.request( + 'GET', + '/v1/memory/describe', + ).catch((): undefined => undefined) + return { + kind: 'remote-http', + base_url: this.baseUrl, + workspace_id: this.workspaceId, + ...(remote ?? {}), + } + } + + private async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = {} + if (this.token) headers.authorization = `Bearer ${this.token}` + if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new RemoteMemoryError( + `Memory service ${method} ${path} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} diff --git a/src/plugins/memory/store.ts b/src/plugins/memory/store.ts index ce47806..9e1158b 100644 --- a/src/plugins/memory/store.ts +++ b/src/plugins/memory/store.ts @@ -1,22 +1,22 @@ /** - * Hierarchical memory store backed by a single JSON file. + * Local-file memory backend. * - * Default path: `~/.thinkfleet/agentmark/memory.json` (mode 0600). - * Separate from Foundations StateStore + Recipes RecipeStore so each - * has independent persistence + backup/sync. + * The default `MemoryBackend` implementation: a single JSON file on + * disk (mode 0600), atomic temp-file + rename writes, soft LRU cap + * via `maxRecords`. Suitable for single-machine setups; for team + * sharing or on-prem/cloud sync, use RemoteMemoryBackend instead. * - * Writes are atomic via temp-file + rename. Reads load once and cache - * in memory; subsequent writes invalidate via re-assignment. - * - * Capacity is bounded by `maxRecords` (default 10,000). When the limit - * is hit, oldest-by-`last_accessed_at` records are evicted first. + * Default path: `~/.thinkfleet/agentmark/memory.json`. Separate from + * Foundations StateStore + Recipes RecipeStore so each has + * independent persistence + backup/sync. */ import { mkdir, readFile, writeFile, rename, chmod, unlink } from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' -export interface MemoryStoreConfig { +export interface LocalFileMemoryBackendConfig { /** Override the on-disk path (mostly for tests). */ path?: string /** Soft cap on total records before LRU-eviction kicks in. Default: 10000. */ @@ -25,6 +25,9 @@ export interface MemoryStoreConfig { defaultScope?: MemoryScope } +/** @deprecated Use LocalFileMemoryBackendConfig. */ +export type MemoryStoreConfig = LocalFileMemoryBackendConfig + interface MemoryFile { version: 1 records: Record @@ -32,13 +35,13 @@ interface MemoryFile { const SCOPE_ORDER: Array = ['session', 'agent', 'user', 'project', 'platform'] -export class MemoryStore { +export class LocalFileMemoryBackend implements MemoryBackend { readonly filePath: string readonly maxRecords: number readonly defaultScope?: MemoryScope private cached: MemoryFile | null = null - constructor(config: MemoryStoreConfig = {}) { + constructor(config: LocalFileMemoryBackendConfig = {}) { this.filePath = config.path ?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'memory.json') this.maxRecords = config.maxRecords ?? 10_000 @@ -166,11 +169,12 @@ export class MemoryStore { } /** Stats helper for `describeSessions`. */ - async describe(): Promise<{ total: number; expired: number; path: string }> { + async describe(): Promise { const file = await this.load() const all = Object.values(file.records) const now = Date.now() return { + kind: 'local-file', total: all.length, expired: all.filter((r) => r.expires_at && r.expires_at < now).length, path: this.filePath, @@ -286,3 +290,13 @@ function generateRecordId(): string { : Math.random().toString(36).slice(2, 16) return `mem_${r}` } + +/** + * Backward-compat alias. Old code did `new MemoryStore({ path })`; that + * still works — it just constructs the local-file backend under its + * descriptive name. + * + * @deprecated Prefer `LocalFileMemoryBackend` or pass a `MemoryBackend` + * directly to `createMemoryPlugin({ backend })`. + */ +export const MemoryStore = LocalFileMemoryBackend diff --git a/src/plugins/recipes/backend.ts b/src/plugins/recipes/backend.ts new file mode 100644 index 0000000..4091fd4 --- /dev/null +++ b/src/plugins/recipes/backend.ts @@ -0,0 +1,22 @@ +/** + * Recipe backend abstraction. + * + * Same pattern as MemoryBackend: an interface so the same plugin works + * against a local-file store (default) or a remote ThinkFleet service + * (on-prem or cloud). The desktop app picks one at construction time. + */ +import type { Recipe } from './types' + +export interface RecipeBackend { + get(name: string): Promise + list(filter?: { target_app?: string }): Promise + save(recipe: Recipe, options?: { on_conflict?: 'replace' | 'fail' }): Promise + delete(name: string): Promise + clear(): Promise + describe(): Promise +} + +export interface RecipeBackendDescription { + kind: string + [k: string]: unknown +} diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts index e79a431..6c43bb3 100644 --- a/src/plugins/recipes/index.ts +++ b/src/plugins/recipes/index.ts @@ -12,19 +12,27 @@ * can carry a `verify` block describing what the diff should look like * after the step lands, and the agent decides what "verified" means. */ -import { RecipeStore } from './store' +import { LocalFileRecipeBackend } from './store' import { resolveRecipe, substitute, applyParameterSchema } from './substitute' import { RECIPES_TOOLS } from './tool-defs' +import type { RecipeBackend } from './backend' import type { Recipe, RecipeStep, RecipeParameter } from './types' import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' export interface RecipesPluginConfig { - /** Override the recipes-file path (defaults to ~/.thinkfleet/agentmark/recipes.json). */ + /** + * Storage backend. Pass a `RemoteRecipeBackend` to point at an + * on-prem or cloud ThinkFleet recipe service. Defaults to a + * `LocalFileRecipeBackend` at ~/.thinkfleet/agentmark/recipes.json. + */ + backend?: RecipeBackend + /** Local-file backend convenience: override the on-disk store path. + * Ignored when `backend` is supplied. */ storePath?: string } export function createRecipesPlugin(config: RecipesPluginConfig = {}): AgentMarkPlugin { - const store = new RecipeStore({ path: config.storePath }) + const store: RecipeBackend = config.backend ?? new LocalFileRecipeBackend({ path: config.storePath }) const handlers: Record = { agentmark_recipe_save: async (args): Promise => { @@ -111,13 +119,17 @@ export function createRecipesPlugin(config: RecipesPluginConfig = {}): AgentMark }, } + const isLocalFile = store instanceof LocalFileRecipeBackend + return { name: 'recipes', version: '0.1.0', tools: RECIPES_TOOLS, handlers, describeSessions: () => ({ - recipes: { store_path: store.filePath }, + recipes: isLocalFile + ? { kind: 'local-file', store_path: (store as LocalFileRecipeBackend).filePath } + : { kind: 'custom', backend_class: store.constructor.name }, }), } } @@ -135,7 +147,11 @@ function recipeSummary(r: Recipe): Record { } } -export { RecipeStore } from './store' +export { LocalFileRecipeBackend, RecipeStore } from './store' +export type { LocalFileRecipeBackendConfig, RecipeStoreConfig } from './store' +export { RemoteRecipeBackend, RemoteRecipeError } from './remote-backend' +export type { RemoteRecipeBackendConfig } from './remote-backend' +export type { RecipeBackend, RecipeBackendDescription } from './backend' export { resolveRecipe, substitute, applyParameterSchema } from './substitute' export { RECIPES_TOOLS } from './tool-defs' export type { Recipe, RecipeStep, RecipeParameter, RecipeVerification, ResolvedRecipe, ResolvedRecipeStep } from './types' diff --git a/src/plugins/recipes/remote-backend.ts b/src/plugins/recipes/remote-backend.ts new file mode 100644 index 0000000..5c29fc8 --- /dev/null +++ b/src/plugins/recipes/remote-backend.ts @@ -0,0 +1,153 @@ +/** + * Remote (HTTP) recipe backend. + * + * Same auth + workspace pattern as RemoteMemoryBackend. The desktop + * app instantiates this when the user/org is configured for on-prem + * or cloud sync — recipes saved by one teammate become available to + * everyone in the workspace. + * + * Endpoint contract (v1 proposal): + * + * POST /v1/recipes Recipe → Recipe + * GET /v1/recipes/:name → Recipe | null + * GET /v1/recipes?target_app=... → Recipe[] + * DELETE /v1/recipes/:name → { deleted: boolean } + * DELETE /v1/recipes/all → { cleared: true } + * GET /v1/recipes/describe → RecipeBackendDescription + * + * Save semantics: server controls version + timestamps; the on_conflict + * flag is passed as a query param. + */ +import type { RecipeBackend, RecipeBackendDescription } from './backend' +import type { Recipe } from './types' + +export interface RemoteRecipeBackendConfig { + /** Base URL of the recipe service (no trailing slash). */ + baseUrl: string + /** Bearer token. */ + token?: string + /** Optional workspace / team id sent as `X-Workspace-Id`. */ + workspaceId?: string + /** Injectable fetch (defaults to globalThis.fetch). */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15_000. */ + timeoutMs?: number +} + +export class RemoteRecipeError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'RemoteRecipeError' + } +} + +export class RemoteRecipeBackend implements RecipeBackend { + readonly baseUrl: string + readonly workspaceId?: string + private readonly token?: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: RemoteRecipeBackendConfig) { + if (!config.baseUrl) throw new Error('RemoteRecipeBackend: baseUrl is required.') + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.token = config.token + this.workspaceId = config.workspaceId + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async get(name: string): Promise { + try { + return await this.request('GET', `/v1/recipes/${encodeURIComponent(name)}`) + } catch (err) { + if (err instanceof RemoteRecipeError && err.status === 404) return null + throw err + } + } + + async list(filter?: { target_app?: string }): Promise { + const qs = filter?.target_app ? `?target_app=${encodeURIComponent(filter.target_app)}` : '' + return await this.request('GET', `/v1/recipes${qs}`) + } + + async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { + const onConflict = options.on_conflict ?? 'fail' + return await this.request( + 'POST', + `/v1/recipes?on_conflict=${onConflict}`, + recipe, + ) + } + + async delete(name: string): Promise { + try { + const r = await this.request<{ deleted: boolean }>( + 'DELETE', + `/v1/recipes/${encodeURIComponent(name)}`, + ) + return r.deleted === true + } catch (err) { + if (err instanceof RemoteRecipeError && err.status === 404) return false + throw err + } + } + + async clear(): Promise { + await this.request<{ cleared: true }>('DELETE', '/v1/recipes/all') + } + + async describe(): Promise { + const remote = await this.request( + 'GET', + '/v1/recipes/describe', + ).catch((): undefined => undefined) + return { + kind: 'remote-http', + base_url: this.baseUrl, + workspace_id: this.workspaceId, + ...(remote ?? {}), + } + } + + private async request(method: string, p: string, body?: unknown): Promise { + const headers: Record = {} + if (this.token) headers.authorization = `Bearer ${this.token}` + if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${p}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new RemoteRecipeError( + `Recipe service ${method} ${p} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} diff --git a/src/plugins/recipes/store.ts b/src/plugins/recipes/store.ts index 6ce8ee0..771bb19 100644 --- a/src/plugins/recipes/store.ts +++ b/src/plugins/recipes/store.ts @@ -1,32 +1,37 @@ /** - * Recipe storage — single JSON file containing a `{ name → Recipe }` map. + * Local-file recipe backend. * - * Default path: `~/.thinkfleet/agentmark/recipes.json` (mode 0600). - * Separate from the Foundations StateStore so recipes don't bloat the - * general K/V file and so they can be backed up / synced independently. + * The default `RecipeBackend` implementation: single JSON file + * containing a `{ name → Recipe }` map. For team sharing or on-prem/ + * cloud sync, use `RemoteRecipeBackend` instead. * - * Writes use the same temp-file + rename pattern as StateStore. + * Default path: `~/.thinkfleet/agentmark/recipes.json` (mode 0600). + * Atomic temp-file + rename writes. */ import { mkdir, readFile, writeFile, rename, chmod, unlink } from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import type { RecipeBackend, RecipeBackendDescription } from './backend' import type { Recipe } from './types' -export interface RecipeStoreConfig { +export interface LocalFileRecipeBackendConfig { /** Override the on-disk path (mostly for tests). */ path?: string } +/** @deprecated Use LocalFileRecipeBackendConfig. */ +export type RecipeStoreConfig = LocalFileRecipeBackendConfig + interface RecipeFile { version: 1 recipes: Record } -export class RecipeStore { +export class LocalFileRecipeBackend implements RecipeBackend { readonly filePath: string private cached: RecipeFile | null = null - constructor(config: RecipeStoreConfig = {}) { + constructor(config: LocalFileRecipeBackendConfig = {}) { this.filePath = config.path ?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'recipes.json') } @@ -100,6 +105,15 @@ export class RecipeStore { } } + async describe(): Promise { + const file = await this.load() + return { + kind: 'local-file', + store_path: this.filePath, + recipe_count: Object.keys(file.recipes).length, + } + } + private async persist(file: RecipeFile): Promise { this.cached = file await mkdir(path.dirname(this.filePath), { recursive: true }) @@ -111,3 +125,10 @@ export class RecipeStore { await rename(tmp, this.filePath) } } + +/** + * Backward-compat alias for the renamed class. + * @deprecated Use `LocalFileRecipeBackend` directly, or pass a + * `RecipeBackend` to `createRecipesPlugin({ backend })`. + */ +export const RecipeStore = LocalFileRecipeBackend diff --git a/test/memory/remote-backend.test.ts b/test/memory/remote-backend.test.ts new file mode 100644 index 0000000..2799437 --- /dev/null +++ b/test/memory/remote-backend.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for RemoteMemoryBackend — HTTP shape + auth + error handling. + * fetch is mocked; no real network calls. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { RemoteMemoryBackend, RemoteMemoryError } from '../../src/plugins/memory' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('RemoteMemoryBackend — request shape', () => { + it('attaches Bearer token + X-Workspace-Id when configured', async () => { + mockFetch(() => jsonResponse({ record_id: 'mem_x', key: 'k', value: 'v', scope: { type: 'platform' }, version: 1, created_at: '', updated_at: '', access_count: 0 })) + + const backend = new RemoteMemoryBackend({ + baseUrl: 'https://memory.example.com', + token: 'secret', + workspaceId: 'ws_123', + }) + await backend.set({ key: 'k', value: 'v' }) + + expect(requests).toHaveLength(1) + const headers = requests[0].init?.headers as Record + expect(headers.authorization).toBe('Bearer secret') + expect(headers['x-workspace-id']).toBe('ws_123') + expect(headers['content-type']).toBe('application/json') + }) + + it('strips trailing slashes from baseUrl', async () => { + mockFetch(() => jsonResponse(null)) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://memory.example.com///' }) + await backend.get({ key: 'k' }) + expect(requests[0].url).toBe('https://memory.example.com/v1/memory/records/get') + }) + + it('throws if baseUrl is missing', () => { + expect(() => new RemoteMemoryBackend({ baseUrl: '' })).toThrow(/baseUrl is required/) + }) +}) + +describe('RemoteMemoryBackend — verb mapping', () => { + it('set → POST /v1/memory/records', async () => { + mockFetch(() => jsonResponse({ record_id: 'r', key: 'k', value: 'v', scope: { type: 'platform' }, created_at: '', updated_at: '', access_count: 0 })) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + await backend.set({ key: 'k', value: 'v', tags: ['t'], ttlSeconds: 60 }) + + expect(requests[0].url).toBe('https://x.com/v1/memory/records') + expect(requests[0].init?.method).toBe('POST') + const body = JSON.parse(requests[0].init?.body as string) + expect(body).toEqual({ + key: 'k', + value: 'v', + scope: { type: 'platform' }, + tags: ['t'], + ttl_seconds: 60, + }) + }) + + it('get → POST /v1/memory/records/get', async () => { + mockFetch(() => jsonResponse(null)) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + const result = await backend.get({ key: 'k' }) + expect(result).toBeNull() + expect(requests[0].url).toBe('https://x.com/v1/memory/records/get') + expect(requests[0].init?.method).toBe('POST') + }) + + it('deleteById → DELETE /v1/memory/records/:id', async () => { + mockFetch(() => jsonResponse({ deleted: true })) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + const ok = await backend.deleteById('mem_abc') + expect(ok).toBe(true) + expect(requests[0].url).toBe('https://x.com/v1/memory/records/mem_abc') + expect(requests[0].init?.method).toBe('DELETE') + }) + + it('list builds the correct query string', async () => { + mockFetch(() => jsonResponse([])) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + await backend.list({ type: 'project', id: '/repo' }, 'build_', 25) + expect(requests[0].url).toBe('https://x.com/v1/memory/records?scope_type=project&scope_id=%2Frepo&prefix=build_&limit=25') + expect(requests[0].init?.method).toBe('GET') + }) + + it('search → POST /v1/memory/search', async () => { + mockFetch(() => jsonResponse([])) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + await backend.search({ query: 'foo', tags: ['build'] }) + expect(requests[0].url).toBe('https://x.com/v1/memory/search') + expect(JSON.parse(requests[0].init?.body as string)).toEqual({ query: 'foo', tags: ['build'] }) + }) +}) + +describe('RemoteMemoryBackend — error handling', () => { + it('throws RemoteMemoryError with status + body on non-2xx', async () => { + mockFetch(() => jsonResponse({ error: 'unauthorized' }, 401)) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', token: 'bad' }) + try { + await backend.set({ key: 'k', value: 'v' }) + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(RemoteMemoryError) + expect((err as RemoteMemoryError).status).toBe(401) + expect((err as RemoteMemoryError).body).toEqual({ error: 'unauthorized' }) + } + }) + + it('describe() returns "remote-http" kind even when the service has no /describe endpoint', async () => { + mockFetch(() => new Response('', { status: 404 })) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', workspaceId: 'ws_1' }) + const desc = await backend.describe() + expect(desc.kind).toBe('remote-http') + expect(desc.base_url).toBe('https://x.com') + expect(desc.workspace_id).toBe('ws_1') + }) + + it('describe() merges remote server-supplied fields when available', async () => { + mockFetch(() => jsonResponse({ total_records: 42, region: 'us-east-1' })) + const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) + const desc = await backend.describe() + expect(desc.kind).toBe('remote-http') + expect(desc.total_records).toBe(42) + expect(desc.region).toBe('us-east-1') + }) +}) diff --git a/test/recipes/remote-backend.test.ts b/test/recipes/remote-backend.test.ts new file mode 100644 index 0000000..9db5761 --- /dev/null +++ b/test/recipes/remote-backend.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for RemoteRecipeBackend — HTTP shape + 404 handling. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { RemoteRecipeBackend, RemoteRecipeError } from '../../src/plugins/recipes' +import type { Recipe } from '../../src/plugins/recipes' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const SAMPLE: Recipe = { + name: 'test-recipe', + steps: [{ tool: 'agentmark_desktop_execute', args: {} }], + version: 1, + created_at: '', + updated_at: '', +} + +describe('RemoteRecipeBackend — verb mapping', () => { + it('save → POST /v1/recipes with on_conflict query param', async () => { + mockFetch(() => jsonResponse(SAMPLE)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + await backend.save(SAMPLE, { on_conflict: 'replace' }) + expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=replace') + expect(requests[0].init?.method).toBe('POST') + }) + + it('save without on_conflict defaults to "fail"', async () => { + mockFetch(() => jsonResponse(SAMPLE)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + await backend.save(SAMPLE) + expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=fail') + }) + + it('get → GET /v1/recipes/:name', async () => { + mockFetch(() => jsonResponse(SAMPLE)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + const r = await backend.get('test-recipe') + expect(r?.name).toBe('test-recipe') + expect(requests[0].url).toBe('https://r.example.com/v1/recipes/test-recipe') + expect(requests[0].init?.method).toBe('GET') + }) + + it('list with target_app builds the query string', async () => { + mockFetch(() => jsonResponse([SAMPLE])) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + await backend.list({ target_app: 'excel' }) + expect(requests[0].url).toBe('https://r.example.com/v1/recipes?target_app=excel') + }) + + it('delete → DELETE /v1/recipes/:name', async () => { + mockFetch(() => jsonResponse({ deleted: true })) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + const ok = await backend.delete('to-go') + expect(ok).toBe(true) + expect(requests[0].init?.method).toBe('DELETE') + }) +}) + +describe('RemoteRecipeBackend — 404 handling', () => { + it('get returns null on 404 (instead of throwing)', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + const r = await backend.get('missing') + expect(r).toBeNull() + }) + + it('delete returns false on 404 (instead of throwing)', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + const ok = await backend.delete('missing') + expect(ok).toBe(false) + }) + + it('other 4xx/5xx throw RemoteRecipeError', async () => { + mockFetch(() => jsonResponse({ error: 'server_error' }, 500)) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) + try { + await backend.get('boom') + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(RemoteRecipeError) + expect((err as RemoteRecipeError).status).toBe(500) + } + }) +}) + +describe('RemoteRecipeBackend — describe', () => { + it('returns kind=remote-http with base_url + workspace_id', async () => { + mockFetch(() => new Response('', { status: 404 })) + const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com', workspaceId: 'ws_1' }) + const desc = await backend.describe() + expect(desc.kind).toBe('remote-http') + expect(desc.base_url).toBe('https://r.example.com') + expect(desc.workspace_id).toBe('ws_1') + }) +}) + +describe('Backend injection — plugin acceptance', () => { + it('createRecipesPlugin accepts a custom backend', async () => { + const { createRecipesPlugin } = await import('../../src/plugins/recipes') + const plugin = createRecipesPlugin({ + backend: new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }), + }) + expect(plugin.name).toBe('recipes') + // describeSessions reports "custom" when a non-LocalFile backend is used. + const info = plugin.describeSessions?.() + expect((info as { recipes: { kind: string } }).recipes.kind).toBe('custom') + }) +})