UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

227 lines 8.98 kB
/** * Fortemi Storage Adapter * * Routes storage operations through Fortemi's MCP tool surface. Fortemi * is the first-party AIWG semantic-memory project — Rust + PostgreSQL + * pgvector + SKOS + W3C PROV — referenced as "Forte" in #934 and * confirmed as Fortemi in #961. * * Tool surface (per `.aiwg/planning/training-framework/phase-4-fortemi-review.md`): * capture_knowledge - create note (we use this for first writes) * update_note - revise existing note (we use this for re-writes) * get_note - retrieve full note (read) * list_notes - filter/paginate (list) * search - text/semantic/spatial/temporal search (query) * manage_collection - organize notes in folders (we use folder=subsystem * scope to mirror the StorageAdapter contract) * * Path semantics: * note_id = `${subsystem}:${path}` — the adapter passes the * subsystem-relative path; the registry-supplied `subsystem` is * prepended to keep different subsystems' notes from colliding. * * Caveats: * - This adapter ships with the parameter shapes documented in the * planning doc, but those shapes have NOT yet been validated against * a live Fortemi instance. Treat this as alpha. The * `McpClientLike.callTool(name, args)` injection point lets tests * stub freely; real-world parameter mismatches surface as MCP * errors that bubble up to the consumer. * - Delete is implemented via `update_note` with `archived: true` * because Fortemi's MCP surface does not document a direct delete * tool (immutability + versioning is core to the design). * * @design @.aiwg/architecture/storage-design.md (§5.6) * @issue #934 * @issue #961 * @issue #972 */ const DEFAULT_MCP_SERVER = 'fortemi'; export class FortemiAdapter { subsystem; mcpServer; scheme; clientFactory; client = null; constructor(opts) { this.subsystem = opts.subsystem; this.mcpServer = opts.config.mcpServer ?? DEFAULT_MCP_SERVER; this.scheme = opts.config.scheme; this.clientFactory = opts.clientFactory ?? createDefaultMcpClient; } async init() { if (this.client) return; this.client = await this.clientFactory(this.mcpServer); } async close() { if (this.client?.close) { await this.client.close(); } this.client = null; } async getClient() { if (!this.client) await this.init(); if (!this.client) { throw new Error(`storage(fortemi): MCP client unavailable for server "${this.mcpServer}"`); } return this.client; } noteId(path) { if (typeof path !== 'string' || path.length === 0) { throw new Error('storage(fortemi): path must be a non-empty string'); } if (path.includes('\0')) { throw new Error(`storage(fortemi): null bytes not allowed in path "${path}"`); } return `${this.subsystem}:${path}`; } async read(path) { const id = this.noteId(path); const client = await this.getClient(); const result = (await client.callTool('get_note', { note_id: id })); if (!result || result.not_found) return null; const note = result.note; if (!note) return null; return note.revised_content ?? note.content ?? null; } async write(path, content, meta) { const id = this.noteId(path); const client = await this.getClient(); // Try update first; if not found, capture as new. Two calls in the // worst case but idempotent — Fortemi's update_note increments the // version rather than overwriting, which matches the Phase-4 design. const existing = (await client.callTool('get_note', { note_id: id })); if (existing && !existing.not_found && existing.note) { await client.callTool('update_note', { note_id: id, content, metadata: this.buildMetadata(meta), }); } else { await client.callTool('capture_knowledge', { note_id: id, content, scheme: this.scheme, metadata: this.buildMetadata(meta), }); } } async list(prefix) { if (typeof prefix !== 'string') { throw new Error('storage(fortemi): list prefix must be a string'); } const client = await this.getClient(); const subsystemPrefix = `${this.subsystem}:`; const fullPrefix = prefix.length === 0 ? subsystemPrefix : `${subsystemPrefix}${prefix}`; const result = (await client.callTool('list_notes', { id_prefix: fullPrefix, scheme: this.scheme, })); const notes = result?.notes ?? []; return notes .filter((n) => typeof n.note_id === 'string' && n.note_id.startsWith(subsystemPrefix)) .map((n) => { const entry = { path: n.note_id.slice(subsystemPrefix.length), externalId: n.note_id, }; if (typeof n.size === 'number') entry.size = n.size; if (typeof n.updated_at === 'string') { const d = new Date(n.updated_at); if (!Number.isNaN(d.getTime())) entry.modifiedAt = d; } return entry; }); } async delete(path) { // Fortemi's MCP surface does not document a destructive delete — // immutability + versioning is core to the design. We mark the note // archived via update_note instead. This matches the storage-design // contract (delete is "no-op when missing"; here we just suppress // the note from list/read by archiving it). const id = this.noteId(path); const client = await this.getClient(); const existing = (await client.callTool('get_note', { note_id: id })); if (!existing || existing.not_found || !existing.note) return; await client.callTool('update_note', { note_id: id, archived: true, }); } async query(q) { const client = await this.getClient(); const subsystemPrefix = `${this.subsystem}:`; const result = (await client.callTool('search', { query: q, id_prefix: subsystemPrefix, scheme: this.scheme, })); const results = result?.results ?? []; return results .filter((r) => typeof r.note_id === 'string' && r.note_id.startsWith(subsystemPrefix)) .map((r) => ({ path: r.note_id.slice(subsystemPrefix.length), externalId: r.note_id, })); } buildMetadata(meta) { const out = { subsystem: this.subsystem, source: 'aiwg-storage-adapter', }; if (meta?.contentType) out['content_type'] = meta.contentType; if (meta?.frontmatter) out['frontmatter'] = meta.frontmatter; if (this.scheme) out['scheme'] = this.scheme; return out; } } /** * Default MCP client factory. Resolves the server config from AIWG's * McpServerRegistry, spawns the stdio transport, and returns a * connected client. * * Implemented as a lazy import so tests that inject a stub never load * the SDK or touch the registry. */ export const createDefaultMcpClient = async (serverName) => { const { McpServerRegistry } = await import('../../mcp/registry.js'); const registry = new McpServerRegistry(); const server = await registry.get(serverName); if (!server) { throw new Error(`storage(fortemi): MCP server "${serverName}" is not registered. ` + `Add it via "aiwg mcp add ${serverName} --command <cmd>" before using the fortemi backend.`); } if (server.type !== 'stdio') { throw new Error(`storage(fortemi): only stdio MCP servers are supported (got "${server.type}" for "${serverName}")`); } // Lazy import the SDK so tests that inject a stub don't pay the cost const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'); const transport = new StdioClientTransport({ command: server.command ?? '', args: server.args ?? [], env: server.env, }); const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} }); await client.connect(transport); return { async callTool(name, args) { return client.callTool({ name, arguments: args }); }, async close() { await client.close(); }, }; }; //# sourceMappingURL=fortemi.js.map