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
15 changes: 15 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -113,13 +116,20 @@ export type {
RecipeStep,
RecipeParameter,
RecipeVerification,
RecipeBackend,
RecipeBackendDescription,
LocalFileRecipeBackendConfig,
RemoteRecipeBackendConfig,
} from '../plugins/recipes'

// Memory Pack — hierarchical persistent memory for AI agents. Opt-in;
// targeted at IDE coding-assistant integrations where per-session
// amnesia is the dominant UX limitation.
export {
createMemoryPlugin,
LocalFileMemoryBackend,
RemoteMemoryBackend,
RemoteMemoryError,
MemoryStore,
MEMORY_TOOLS,
} from '../plugins/memory'
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/plugins/memory/backend.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryRecord>
get(input: { key: string; scopes?: MemoryScope[] }): Promise<MemoryRecord | null>
deleteById(recordId: string): Promise<boolean>
deleteByKey(key: string, scope: MemoryScope): Promise<boolean>
search(query: MemorySearchQuery): Promise<MemoryRecord[]>
list(scope?: MemoryScope, prefix?: string, limit?: number): Promise<MemoryRecord[]>
clear(): Promise<void>
/** 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<MemoryBackendDescription>
}

export interface MemoryBackendDescription {
kind: string
[k: string]: unknown
}
48 changes: 37 additions & 11 deletions src/plugins/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
}),
}
}
Expand Down Expand Up @@ -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,
Expand Down
170 changes: 170 additions & 0 deletions src/plugins/memory/remote-backend.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryRecord> {
return await this.request<MemoryRecord>('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<MemoryRecord | null> {
const result = await this.request<MemoryRecord | null>('POST', '/v1/memory/records/get', input)
return result ?? null
}

async deleteById(recordId: string): Promise<boolean> {
const r = await this.request<{ deleted: boolean }>(
'DELETE',
`/v1/memory/records/${encodeURIComponent(recordId)}`,
)
return r.deleted === true
}

async deleteByKey(key: string, scope: MemoryScope): Promise<boolean> {
const r = await this.request<{ deleted: boolean }>('DELETE', '/v1/memory/records', { key, scope })
return r.deleted === true
}

async search(query: MemorySearchQuery): Promise<MemoryRecord[]> {
return await this.request<MemoryRecord[]>('POST', '/v1/memory/search', query)
}

async list(scope?: MemoryScope, prefix?: string, limit?: number): Promise<MemoryRecord[]> {
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<MemoryRecord[]>(
'GET',
`/v1/memory/records${qs ? '?' + qs : ''}`,
)
}

async clear(): Promise<void> {
await this.request<{ cleared: true }>('DELETE', '/v1/memory/records/all')
}

async describe(): Promise<MemoryBackendDescription> {
const remote = await this.request<MemoryBackendDescription | undefined>(
'GET',
'/v1/memory/describe',
).catch((): undefined => undefined)
return {
kind: 'remote-http',
base_url: this.baseUrl,
workspace_id: this.workspaceId,
...(remote ?? {}),
}
}

private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {}
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
}
}
Loading
Loading