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
14 changes: 14 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ export type {
SpeakOptions,
} from '../plugins/system'

// Recipes Pack — durable named playbooks the agent learns once + replays.
export {
createRecipesPlugin,
RecipeStore,
RECIPES_TOOLS,
} from '../plugins/recipes'
export type {
RecipesPluginConfig,
Recipe,
RecipeStep,
RecipeParameter,
RecipeVerification,
} from '../plugins/recipes'

// Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the
// default plugin set. Pass it explicitly via `createMcpServer({ plugins })`.
export {
Expand Down
157 changes: 157 additions & 0 deletions src/plugins/recipes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Recipes Pack — durable named playbooks the agent learns once + replays.
*
* Recipes are stored as JSON; `agentmark_recipe_get` returns a resolved
* plan (parameter substitutions applied) and the AI dispatches each
* step itself. The plugin deliberately does NOT auto-execute server-side
* — keeps the steps visible to the AI's reasoning chain and avoids the
* recursive-dispatch coupling between the recipe plugin and the
* surrounding Dispatcher.
*
* Pairs naturally with agentmark_desktop_diff: each step in a recipe
* 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 { resolveRecipe, substitute, applyParameterSchema } from './substitute'
import { RECIPES_TOOLS } from './tool-defs'
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). */
storePath?: string
}

export function createRecipesPlugin(config: RecipesPluginConfig = {}): AgentMarkPlugin {
const store = new RecipeStore({ path: config.storePath })

const handlers: Record<string, ToolHandler> = {
agentmark_recipe_save: async (args): Promise<DispatchResult> => {
const name = requireString(args, 'name')
const steps = requireArray<RecipeStep>(args, 'steps')
for (let i = 0; i < steps.length; i++) {
const s = steps[i] as unknown as Record<string, unknown>
if (typeof s.tool !== 'string') {
return { text: `steps[${i}].tool must be a string.`, isError: true }
}
if (!s.args || typeof s.args !== 'object') {
return { text: `steps[${i}].args must be an object.`, isError: true }
}
}

const now = new Date().toISOString()
const recipe: Recipe = {
name,
description: typeof args.description === 'string' ? args.description : undefined,
target_app: typeof args.target_app === 'string' ? args.target_app : undefined,
parameters: Array.isArray(args.parameters) ? (args.parameters as RecipeParameter[]) : undefined,
steps,
version: 0, // store bumps this
created_at: now,
updated_at: now,
}

const onConflict = args.on_conflict === 'replace' ? 'replace' : 'fail'
const saved = await store.save(recipe, { on_conflict: onConflict })
return { text: JSON.stringify({ saved: true, ...recipeSummary(saved) }, null, 2) }
},

agentmark_recipe_list: async (args): Promise<DispatchResult> => {
const target = typeof args.target_app === 'string' ? args.target_app : undefined
const recipes = await store.list({ target_app: target })
return {
text: JSON.stringify({
count: recipes.length,
recipes: recipes.map(recipeSummary),
}, null, 2),
}
},

agentmark_recipe_get: async (args): Promise<DispatchResult> => {
const name = requireString(args, 'name')
const recipe = await store.get(name)
if (!recipe) {
return { text: `Unknown recipe: ${name}`, isError: true }
}

const paramsSupplied = args.params && typeof args.params === 'object' && !Array.isArray(args.params)
if (!paramsSupplied) {
return { text: JSON.stringify({ resolved: false, recipe }, null, 2) }
}

try {
const supplied = args.params as Record<string, unknown>
const applied = applyParameterSchema(recipe.parameters, supplied)
const resolvedSteps = recipe.steps.map((step) => ({
...step,
args: substitute(step.args, applied) as Record<string, unknown>,
}))
return {
text: JSON.stringify({
resolved: true,
recipe: {
...recipeSummary(recipe),
description: recipe.description,
target_app: recipe.target_app,
},
resolved_with: applied,
steps: resolvedSteps,
}, null, 2),
}
} catch (err) {
return { text: (err as Error).message, isError: true }
}
},

agentmark_recipe_delete: async (args): Promise<DispatchResult> => {
const name = requireString(args, 'name')
const existed = await store.delete(name)
return { text: JSON.stringify({ name, existed }, null, 2) }
},
}

return {
name: 'recipes',
version: '0.1.0',
tools: RECIPES_TOOLS,
handlers,
describeSessions: () => ({
recipes: { store_path: store.filePath },
}),
}
}

function recipeSummary(r: Recipe): Record<string, unknown> {
return {
name: r.name,
description: r.description,
target_app: r.target_app,
parameter_count: r.parameters?.length ?? 0,
step_count: r.steps.length,
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}

export { RecipeStore } from './store'
export { resolveRecipe, substitute, applyParameterSchema } from './substitute'
export { RECIPES_TOOLS } from './tool-defs'
export type { Recipe, RecipeStep, RecipeParameter, RecipeVerification, ResolvedRecipe, ResolvedRecipeStep } from './types'

function requireString(args: Record<string, unknown>, key: string): string {
const v = args[key]
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`Missing required argument: ${key}`)
}
return v
}

function requireArray<T>(args: Record<string, unknown>, key: string): T[] {
const v = args[key]
if (!Array.isArray(v) || v.length === 0) {
throw new Error(`Argument ${key} must be a non-empty array.`)
}
return v as T[]
}
113 changes: 113 additions & 0 deletions src/plugins/recipes/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Recipe storage — single JSON file containing a `{ name → Recipe }` map.
*
* 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.
*
* Writes use the same temp-file + rename pattern as StateStore.
*/
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 { Recipe } from './types'

export interface RecipeStoreConfig {
/** Override the on-disk path (mostly for tests). */
path?: string
}

interface RecipeFile {
version: 1
recipes: Record<string, Recipe>
}

export class RecipeStore {
readonly filePath: string
private cached: RecipeFile | null = null

constructor(config: RecipeStoreConfig = {}) {
this.filePath = config.path
?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'recipes.json')
}

async get(name: string): Promise<Recipe | null> {
const file = await this.load()
return file.recipes[name] ?? null
}

async list(filter?: { target_app?: string }): Promise<Recipe[]> {
const file = await this.load()
let recipes = Object.values(file.recipes)
if (filter?.target_app) {
recipes = recipes.filter((r) => r.target_app === filter.target_app)
}
// Stable order by name to make listings predictable.
recipes.sort((a, b) => a.name.localeCompare(b.name))
return recipes
}

async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise<Recipe> {
const file = await this.load()
const existing = file.recipes[recipe.name]
if (existing && options.on_conflict !== 'replace') {
throw new Error(
`Recipe "${recipe.name}" already exists. `
+ `Pass on_conflict="replace" to overwrite or pick a different name.`,
)
}
const now = new Date().toISOString()
const stored: Recipe = {
...recipe,
version: (existing?.version ?? 0) + 1,
created_at: existing?.created_at ?? recipe.created_at ?? now,
updated_at: now,
}
file.recipes[recipe.name] = stored
await this.persist(file)
return stored
}

async delete(name: string): Promise<boolean> {
const file = await this.load()
if (!(name in file.recipes)) return false
delete file.recipes[name]
await this.persist(file)
return true
}

async clear(): Promise<void> {
this.cached = { version: 1, recipes: {} }
await unlink(this.filePath).catch(() => {})
}

private async load(): Promise<RecipeFile> {
if (this.cached) return this.cached
try {
const raw = await readFile(this.filePath, 'utf8')
const parsed = JSON.parse(raw) as RecipeFile
if (parsed.version !== 1 || !parsed.recipes || typeof parsed.recipes !== 'object') {
throw new Error(`Malformed recipe file at ${this.filePath}: unexpected schema.`)
}
this.cached = parsed
return parsed
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
this.cached = { version: 1, recipes: {} }
return this.cached
}
throw err
}
}

private async persist(file: RecipeFile): Promise<void> {
this.cached = file
await mkdir(path.dirname(this.filePath), { recursive: true })
const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`
await writeFile(tmp, JSON.stringify(file, null, 2), { encoding: 'utf8' })
await chmod(tmp, 0o600).catch(() => {
// Windows ACL semantics swallow chmod; not fatal.
})
await rename(tmp, this.filePath)
}
}
Loading
Loading