diff --git a/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift b/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift index 9df56699..9b7acd9e 100644 --- a/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift +++ b/Packages/OsaurusCore/Managers/Plugin/PluginManager.swift @@ -227,6 +227,7 @@ final class PluginManager { if !loaded.skills.isEmpty { await SkillManager.shared.unregisterPluginSkills(pluginId: loaded.plugin.id) } + PluginDocumentRegistry.unregisterAll(pluginId: loaded.plugin.id) await loaded.plugin.shutdown() PluginHostContext.getContext(for: loaded.plugin.id)?.teardown() lastPushedTunnelURL.removeValue(forKey: loaded.plugin.id) @@ -266,6 +267,7 @@ final class PluginManager { if !loaded.skills.isEmpty { await SkillManager.shared.unregisterPluginSkills(pluginId: loaded.plugin.id) } + PluginDocumentRegistry.unregisterAll(pluginId: loaded.plugin.id) await loaded.plugin.shutdown() PluginHostContext.getContext(for: loaded.plugin.id)?.teardown() lastPushedTunnelURL.removeValue(forKey: loaded.plugin.id) diff --git a/Packages/OsaurusCore/Models/Plugin/ExternalPlugin.swift b/Packages/OsaurusCore/Models/Plugin/ExternalPlugin.swift index 77d6fa52..ade125f3 100644 --- a/Packages/OsaurusCore/Models/Plugin/ExternalPlugin.swift +++ b/Packages/OsaurusCore/Models/Plugin/ExternalPlugin.swift @@ -124,6 +124,11 @@ typealias osr_log_structured_t = // Host-side string free (added in v6) typealias osr_host_free_string_t = @convention(c) (UnsafePointer?) -> Void +// Document-format registration (added in v7) +typealias osr_register_parser_t = @convention(c) (UnsafePointer?) -> UnsafePointer? +typealias osr_register_emitter_t = @convention(c) (UnsafePointer?) -> UnsafePointer? +typealias osr_unregister_format_t = @convention(c) (UnsafePointer?) -> UnsafePointer? + /// Frozen layout — field order and padding must match `osaurus_plugin.h` /// exactly (see `PluginHostAPIStructLayoutTests`). Swift plugins that mirror /// this struct must not skip middle fields (e.g. omitting v5 `log_structured` @@ -181,6 +186,11 @@ struct osr_host_api { // Host-side string free (added in v6) var free_string: osr_host_free_string_t? + + // Document-format registration (added in v7) + var register_parser: osr_register_parser_t? + var register_emitter: osr_register_emitter_t? + var unregister_format: osr_unregister_format_t? } struct osr_plugin_api { diff --git a/Packages/OsaurusCore/Services/Plugin/PluginBackedDocumentAdapter.swift b/Packages/OsaurusCore/Services/Plugin/PluginBackedDocumentAdapter.swift new file mode 100644 index 00000000..8bd83e62 --- /dev/null +++ b/Packages/OsaurusCore/Services/Plugin/PluginBackedDocumentAdapter.swift @@ -0,0 +1,135 @@ +// +// PluginBackedDocumentAdapter.swift +// osaurus +// +// Swift shims that implement `DocumentFormatAdapter` / `DocumentFormatEmitter` +// by forwarding to an external plugin via the existing +// `invoke(type:id:payload:)` callback. Created when a plugin calls the +// register_parser / register_emitter host callbacks from its `init` entry +// point. +// +// The plugin side speaks JSON; these shims translate between the +// typed `DocumentFormatRegistry` contract and the plugin's wire format. +// Only the `textFallback` representation is surfaced today — richer +// types (`Workbook`, `PDFDocumentRepresentation`) are left for future +// PRs that extend the plugin response schema. +// + +import Foundation + +/// Opaque invoker the plugin host wires up so shims don't need to know +/// about `ExternalPlugin` internals. The host side (PluginHostAPI) is +/// the only producer; tests use a closure-backed fake. +public protocol PluginDocumentInvoker: Sendable { + func invoke(type: String, id: String, payload: String) async -> String +} + +struct PluginBackedAdapter: DocumentFormatAdapter { + let formatId: String + let extensions: Set + let invoker: any PluginDocumentInvoker + + func canHandle(url: URL, uti: String?) -> Bool { + extensions.contains(url.pathExtension.lowercased()) + } + + func parse(url: URL, sizeLimit: Int64) async throws -> StructuredDocument { + let fileSize = Int64((try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) + if sizeLimit > 0, fileSize > sizeLimit { + throw DocumentAdapterError.sizeLimitExceeded(actual: fileSize, limit: sizeLimit) + } + + let payload: [String: Any] = [ + "path": url.path, + "size_limit": Int(sizeLimit), + ] + let payloadJSON = Self.serialize(payload) + let responseText = await invoker.invoke(type: "parser", id: formatId, payload: payloadJSON) + + guard let response = Self.decodeResponse(responseText) else { + throw DocumentAdapterError.readFailed( + underlying: "plugin returned malformed parser response for format '\(formatId)'" + ) + } + if response.ok == false { + throw DocumentAdapterError.readFailed( + underlying: response.error ?? "plugin parser failed for format '\(formatId)'" + ) + } + + let text = response.textFallback ?? "" + guard !text.isEmpty else { + throw DocumentAdapterError.emptyContent + } + + return StructuredDocument( + formatId: formatId, + filename: response.filename ?? url.lastPathComponent, + fileSize: response.fileSize ?? fileSize, + representation: AnyStructuredRepresentation( + formatId: formatId, + underlying: PlainTextRepresentation(text: text) + ), + textFallback: text + ) + } + + // MARK: - Response parsing + + struct ParserResponse { + var ok: Bool + var error: String? + var textFallback: String? + var filename: String? + var fileSize: Int64? + } + + static func decodeResponse(_ raw: String) -> ParserResponse? { + guard let data = raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return ParserResponse( + ok: (obj["ok"] as? Bool) ?? false, + error: obj["error"] as? String, + textFallback: obj["text_fallback"] as? String, + filename: obj["filename"] as? String, + fileSize: (obj["file_size"] as? NSNumber)?.int64Value + ) + } + + static func serialize(_ dict: [String: Any]) -> String { + (try? JSONSerialization.data(withJSONObject: dict)) + .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } +} + +struct PluginBackedEmitter: DocumentFormatEmitter { + let formatId: String + let invoker: any PluginDocumentInvoker + + func canEmit(_ document: StructuredDocument) -> Bool { + document.formatId == formatId + } + + func emit(_ document: StructuredDocument, to url: URL) async throws { + let payload: [String: Any] = [ + "destination": url.path, + "filename": document.filename, + "text": document.textFallback, + ] + let payloadJSON = PluginBackedAdapter.serialize(payload) + let responseText = await invoker.invoke(type: "emitter", id: formatId, payload: payloadJSON) + + guard let data = responseText.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw DocumentAdapterError.writeFailed( + underlying: "plugin returned malformed emitter response for format '\(formatId)'" + ) + } + if (obj["ok"] as? Bool) != true { + let message = (obj["error"] as? String) ?? "plugin emitter failed" + throw DocumentAdapterError.writeFailed(underlying: message) + } + } +} diff --git a/Packages/OsaurusCore/Services/Plugin/PluginDocumentRegistry.swift b/Packages/OsaurusCore/Services/Plugin/PluginDocumentRegistry.swift new file mode 100644 index 00000000..ce24c92d --- /dev/null +++ b/Packages/OsaurusCore/Services/Plugin/PluginDocumentRegistry.swift @@ -0,0 +1,159 @@ +// +// PluginDocumentRegistry.swift +// osaurus +// +// Bridge between the plugin lifecycle and `DocumentFormatRegistry`. +// Plugins reach it through host callbacks (introduced in v7 as +// `register_parser` / `register_emitter` entry points in the osaurus +// plugin ABI header) while Swift callers use this module directly. +// +// Responsibilities: +// - Translate the plugin's JSON registration request into a +// `DocumentFormatAdapter` / `DocumentFormatEmitter` shim. +// - Track which plugin owns which `formatId` so a plugin unload can +// tear down only its own adapters, not an in-tree one that +// happens to share a format name. +// - Return a stable JSON response shape so the plugin side can log / +// report failures without needing Swift types. +// + +import Foundation + +public enum PluginDocumentRegistry { + private static let lock = NSLock() + // Maps format_id -> plugin_id. Keeps ownership out of the shared + // `DocumentFormatRegistry` (which has no concept of owners) so one + // plugin cannot unregister another's format. + nonisolated(unsafe) private static var ownership: [String: String] = [:] + + /// Register a plugin-backed parser. `requestJSON` matches the + /// `register_parser` contract from `osaurus_plugin.h`. On success + /// the shim adapter immediately appears in `DocumentFormatRegistry.shared`. + @discardableResult + public static func registerParser( + requestJSON: String, + invoker: any PluginDocumentInvoker, + registry: DocumentFormatRegistry = .shared + ) -> String { + guard let req = parse(requestJSON) else { + return response(ok: false, error: "request JSON must include `plugin_id`, `format_id`, and `extensions`") + } + if !claimOwnership(pluginId: req.pluginId, formatId: req.formatId) { + return response( + ok: false, + error: "format '\(req.formatId)' already registered by another plugin" + ) + } + registry.register( + adapter: PluginBackedAdapter( + formatId: req.formatId, + extensions: req.extensions, + invoker: invoker + ) + ) + return response(ok: true) + } + + /// Register a plugin-backed emitter. Same JSON shape as `registerParser`; + /// the `extensions` field is accepted but ignored because emitter + /// routing is `canEmit(document)`-based. + @discardableResult + public static func registerEmitter( + requestJSON: String, + invoker: any PluginDocumentInvoker, + registry: DocumentFormatRegistry = .shared + ) -> String { + guard let req = parse(requestJSON) else { + return response(ok: false, error: "request JSON must include `plugin_id` and `format_id`") + } + if !claimOwnership(pluginId: req.pluginId, formatId: req.formatId) { + return response( + ok: false, + error: "format '\(req.formatId)' already registered by another plugin" + ) + } + registry.register( + emitter: PluginBackedEmitter(formatId: req.formatId, invoker: invoker) + ) + return response(ok: true) + } + + /// Unregister every adapter/emitter/streamer associated with a + /// plugin-owned `formatId`. Declines to unregister in-tree formats + /// so a buggy plugin can't strip the built-in XLSX / PDF adapters. + @discardableResult + public static func unregisterFormat( + requestJSON: String, + registry: DocumentFormatRegistry = .shared + ) -> String { + guard let req = parse(requestJSON) else { + return response(ok: false, error: "request JSON must include `plugin_id` and `format_id`") + } + guard releaseOwnership(pluginId: req.pluginId, formatId: req.formatId) else { + return response(ok: false, error: "format '\(req.formatId)' not owned by this plugin") + } + registry.unregisterAll(formatId: req.formatId) + return response(ok: true) + } + + /// Tear down every format a plugin registered. Called by + /// `PluginManager` during plugin unload so the shared registry + /// doesn't carry stale pointers. + public static func unregisterAll(pluginId: String, registry: DocumentFormatRegistry = .shared) { + lock.lock() + let owned = ownership.filter { $0.value == pluginId }.map(\.key) + for format in owned { ownership.removeValue(forKey: format) } + lock.unlock() + for format in owned { + registry.unregisterAll(formatId: format) + } + } + + // MARK: - Internals + + struct Request { + let pluginId: String + let formatId: String + let extensions: Set + } + + static func parse(_ json: String) -> Request? { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let pluginId = obj["plugin_id"] as? String, !pluginId.isEmpty, + let formatId = obj["format_id"] as? String, !formatId.isEmpty + else { return nil } + let rawExtensions = (obj["extensions"] as? [String]) ?? [] + let cleaned = rawExtensions.map { + $0.hasPrefix(".") ? String($0.dropFirst()) : $0 + } + return Request( + pluginId: pluginId, + formatId: formatId, + extensions: Set(cleaned.map { $0.lowercased() }) + ) + } + + private static func claimOwnership(pluginId: String, formatId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + if let existing = ownership[formatId], existing != pluginId { return false } + ownership[formatId] = pluginId + return true + } + + private static func releaseOwnership(pluginId: String, formatId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard ownership[formatId] == pluginId else { return false } + ownership.removeValue(forKey: formatId) + return true + } + + private static func response(ok: Bool, error: String? = nil) -> String { + var payload: [String: Any] = ["ok": ok] + if let error { payload["error"] = error } + let data = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data() + return String(data: data, encoding: .utf8) ?? "{}" + } +} diff --git a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift index 42cf8cec..af9eb31f 100644 --- a/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift +++ b/Packages/OsaurusCore/Services/Plugin/PluginHostAPI.swift @@ -2235,7 +2235,7 @@ final class PluginHostContext: @unchecked Sendable { let ptr = UnsafeMutablePointer.allocate(capacity: 1) ptr.initialize( to: osr_host_api( - // v6 surface — frozen layout. Two trampoline slots + // v7 surface — frozen layout. Two trampoline slots // (`dispatch_clarify`, `dispatch_add_issue`) remain wired // for ABI compat but return structured `not_supported` JSON. // `get_active_agent_id` (v4) lets plugins key per-agent @@ -2246,7 +2246,7 @@ final class PluginHostContext: @unchecked Sendable { // plugins don't accidentally route host-allocated // pointers through their own `free_string` callback, // which can corrupt the heap if it isn't plain `free`. - version: 6, + version: 7, config_get: PluginHostContext.trampolineConfigGet, config_set: PluginHostContext.trampolineConfigSet, config_delete: PluginHostContext.trampolineConfigDelete, @@ -2270,7 +2270,10 @@ final class PluginHostContext: @unchecked Sendable { complete_cancel: PluginHostContext.trampolineCompleteCancel, get_active_agent_id: PluginHostContext.trampolineGetActiveAgentId, log_structured: PluginHostContext.trampolineLogStructured, - free_string: PluginHostContext.trampolineHostFreeString + free_string: PluginHostContext.trampolineHostFreeString, + register_parser: PluginHostContext.trampolineRegisterParser, + register_emitter: PluginHostContext.trampolineRegisterEmitter, + unregister_format: PluginHostContext.trampolineUnregisterFormat ) ) hostAPIPtr = ptr @@ -2651,6 +2654,30 @@ private final class ResultBox: @unchecked Sendable { var value: T? } +private struct PluginManagerDocumentInvoker: PluginDocumentInvoker { + let pluginId: String + + func invoke(type: String, id: String, payload: String) async -> String { + guard let plugin = await MainActor.run(body: { + PluginManager.shared.loadedPlugin(for: pluginId)?.plugin + }) else { + return PluginHostContext.jsonString([ + "ok": false, + "error": "plugin '\(pluginId)' is not loaded", + ]) + } + + do { + return try await plugin.invoke(type: type, id: id, payload: payload) + } catch { + return PluginHostContext.jsonString([ + "ok": false, + "error": error.localizedDescription, + ]) + } + } +} + extension PluginHostContext { /// Dedicated GCD queue used to run the inner async-bridge `Task` so that /// the cooperative thread pool's executor cannot deadlock with the @@ -2940,6 +2967,10 @@ extension PluginHostContext { return String(match[colonQuote ..< match.index(before: match.endIndex)]) } + private static func pluginIdForDocumentRegistration(requestJSON: String, fallback: String) -> String { + extractJSONStringValue(from: requestJSON, key: "plugin_id") ?? fallback + } + /// Maps a top-level `error` code in a host response envelope to the /// closest HTTP status for Insights/observability. Returns nil if the /// response does not contain a string-typed `error` key — used as the @@ -2991,6 +3022,38 @@ extension PluginHostContext { ctx.configDelete(key: key) } + // MARK: Document Registration Trampolines (v7) + + static let trampolineRegisterParser: osr_register_parser_t = { requestPtr in + withActiveContext(requestPtr: requestPtr) { ctx, json in + let pluginId = pluginIdForDocumentRegistration(requestJSON: json, fallback: ctx.pluginId) + return makeCString( + PluginDocumentRegistry.registerParser( + requestJSON: json, + invoker: PluginManagerDocumentInvoker(pluginId: pluginId) + ) + ) + } + } + + static let trampolineRegisterEmitter: osr_register_emitter_t = { requestPtr in + withActiveContext(requestPtr: requestPtr) { ctx, json in + let pluginId = pluginIdForDocumentRegistration(requestJSON: json, fallback: ctx.pluginId) + return makeCString( + PluginDocumentRegistry.registerEmitter( + requestJSON: json, + invoker: PluginManagerDocumentInvoker(pluginId: pluginId) + ) + ) + } + } + + static let trampolineUnregisterFormat: osr_unregister_format_t = { requestPtr in + withActiveContext(requestPtr: requestPtr) { _, json in + makeCString(PluginDocumentRegistry.unregisterFormat(requestJSON: json)) + } + } + // MARK: Agent Context Introspection (v4) /// Returns the TLS-resolved active agent UUID as a heap-allocated diff --git a/Packages/OsaurusCore/Tests/Documents/PluginDocumentRegistryTests.swift b/Packages/OsaurusCore/Tests/Documents/PluginDocumentRegistryTests.swift new file mode 100644 index 00000000..06a70f34 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Documents/PluginDocumentRegistryTests.swift @@ -0,0 +1,252 @@ +// +// PluginDocumentRegistryTests.swift +// osaurusTests +// +// Covers the plugin → host bridge for document format registration. +// The end-to-end path (plugin C dylib → host-provided callback → +// `PluginDocumentRegistry.registerParser` → `DocumentFormatRegistry` → +// back through the plugin's `invoke`) is exercised here at the Swift +// level with a fake `PluginDocumentInvoker`, so the registry logic +// and the shim adapter both get pinned without a compiled test +// plugin. The missing piece — `PluginManager` wiring its plugins' +// `invoke` pointers into this API — lands with a follow-up PR. +// + +import Foundation +import Testing + +@testable import OsaurusCore + +@Suite("PluginDocumentRegistry", .serialized) +struct PluginDocumentRegistryTests { + + init() { + // Ensure every test starts with a clean ownership map even when + // an earlier test failed mid-run. + PluginDocumentRegistry.unregisterAll(pluginId: "com.example.a") + PluginDocumentRegistry.unregisterAll(pluginId: "com.example.b") + } + + // MARK: - Registration happy path + + @Test func registerParser_routesThroughSharedRegistry() async throws { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker( + onInvoke: { _, _, _ in + #"{"ok": true, "text_fallback": "parsed body", "filename": "a.wacky"}"# + } + ) + + let response = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": [".wacky"]} + """#, + invoker: invoker, + registry: registry + ) + #expect(response.contains(#""ok":true"#)) + + let url = URL(fileURLWithPath: "/tmp/foo.wacky") + guard let adapter = registry.adapter(for: url) else { + Issue.record("expected registered adapter") + return + } + #expect(adapter.formatId == "wacky") + } + + @Test func registeredAdapter_parseInvokesPluginAndReturnsDocument() async throws { + let registry = DocumentFormatRegistry() + let invokedType = LockedBox(nil) + let invokedId = LockedBox(nil) + let invoker = FakeInvoker { type, id, _ in + invokedType.set(type) + invokedId.set(id) + return #"{"ok": true, "text_fallback": "plugin-parsed text", "filename": "demo.wacky"}"# + } + + _ = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + + let url = try Self.writeFile(content: "raw", ext: "wacky") + defer { try? FileManager.default.removeItem(at: url) } + guard let adapter = registry.adapter(for: url) else { + Issue.record("no adapter"); return + } + + let document = try await adapter.parse(url: url, sizeLimit: 0) + #expect(document.textFallback == "plugin-parsed text") + #expect(document.filename == "demo.wacky") + #expect(invokedType.get() == "parser") + #expect(invokedId.get() == "wacky") + } + + @Test func registeredAdapter_pluginErrorSurfacesAsReadFailed() async throws { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in + #"{"ok": false, "error": "parser refused"}"# + } + _ = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + + let url = try Self.writeFile(content: "x", ext: "wacky") + defer { try? FileManager.default.removeItem(at: url) } + guard let adapter = registry.adapter(for: url) else { + Issue.record("no adapter"); return + } + await #expect(throws: DocumentAdapterError.self) { + _ = try await adapter.parse(url: url, sizeLimit: 0) + } + } + + // MARK: - Emitter + + @Test func registerEmitter_routesByCanEmit() async throws { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in #"{"ok": true}"# } + _ = PluginDocumentRegistry.registerEmitter( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky"} + """#, + invoker: invoker, + registry: registry + ) + + let doc = StructuredDocument( + formatId: "wacky", + filename: "a.wacky", + fileSize: 0, + representation: AnyStructuredRepresentation( + formatId: "wacky", + underlying: PlainTextRepresentation(text: "hi") + ), + textFallback: "hi" + ) + guard let emitter = registry.emitter(for: doc) else { + Issue.record("no emitter"); return + } + let dest = FileManager.default.temporaryDirectory + .appendingPathComponent("pd-\(UUID().uuidString).wacky") + try await emitter.emit(doc, to: dest) + } + + // MARK: - Ownership + + @Test func anotherPluginCannotOverwriteRegistration() { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in #"{"ok": true}"# } + + let first = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + let second = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.b", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + #expect(first.contains(#""ok":true"#)) + #expect(second.contains(#""ok":false"#)) + #expect(second.contains("already registered")) + } + + @Test func unregisterByOtherPluginIsRejected() { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in #"{"ok": true}"# } + _ = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + let response = PluginDocumentRegistry.unregisterFormat( + requestJSON: #""" + {"plugin_id": "com.example.b", "format_id": "wacky"} + """#, + registry: registry + ) + #expect(response.contains(#""ok":false"#)) + } + + @Test func unregisterAll_dropsAdaptersOwnedByPlugin() { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in #"{"ok": true}"# } + _ = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "wacky", "extensions": ["wacky"]} + """#, + invoker: invoker, + registry: registry + ) + _ = PluginDocumentRegistry.registerParser( + requestJSON: #""" + {"plugin_id": "com.example.a", "format_id": "nutty", "extensions": ["nutty"]} + """#, + invoker: invoker, + registry: registry + ) + PluginDocumentRegistry.unregisterAll(pluginId: "com.example.a", registry: registry) + #expect(registry.adapter(for: URL(fileURLWithPath: "/tmp/a.wacky")) == nil) + #expect(registry.adapter(for: URL(fileURLWithPath: "/tmp/a.nutty")) == nil) + } + + // MARK: - Malformed input + + @Test func malformedRegistrationReturnsErrorEnvelope() { + let registry = DocumentFormatRegistry() + let invoker = FakeInvoker { _, _, _ in #"{"ok": true}"# } + let response = PluginDocumentRegistry.registerParser( + requestJSON: "not json", + invoker: invoker, + registry: registry + ) + #expect(response.contains(#""ok":false"#)) + } + + // MARK: - Fixtures + + private struct FakeInvoker: PluginDocumentInvoker { + let onInvoke: @Sendable (String, String, String) -> String + func invoke(type: String, id: String, payload: String) async -> String { + onInvoke(type, id, payload) + } + } + + private static func writeFile(content: String, ext: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("pd-\(UUID().uuidString).\(ext)") + try content.write(to: url, atomically: true, encoding: .utf8) + return url + } +} + +/// Tiny lock-box so test closures can capture and later read a value +/// across the async/Sendable boundary without per-test actor plumbing. +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value? + init(_ value: Value?) { self.value = value } + func get() -> Value? { + lock.lock(); defer { lock.unlock() } + return value + } + func set(_ newValue: Value?) { + lock.lock(); defer { lock.unlock() } + value = newValue + } +} diff --git a/Packages/OsaurusCore/Tests/Plugin/PluginCompleteCancelTests.swift b/Packages/OsaurusCore/Tests/Plugin/PluginCompleteCancelTests.swift index 1c3f8215..7f0781c3 100644 --- a/Packages/OsaurusCore/Tests/Plugin/PluginCompleteCancelTests.swift +++ b/Packages/OsaurusCore/Tests/Plugin/PluginCompleteCancelTests.swift @@ -40,10 +40,10 @@ struct PluginCompleteCancelTests { // deallocating it ourselves. let ptr = ctx.buildHostAPI() let api = ptr.pointee - // Host struct version reflects the current surface (v6 since - // the host-side `free_string` callback was added on top of the - // v5 `log_structured`). - #expect(api.version == 6) + // Host struct version reflects the current surface (v7 since + // document-format registration was added on top of the v6 + // host-side `free_string` callback). + #expect(api.version == 7) // The cancel trampoline is wired (non-nil) so plugins can call it. #expect(api.complete_cancel != nil) } diff --git a/Packages/OsaurusCore/Tests/Plugin/PluginHostAPIStructLayoutTests.swift b/Packages/OsaurusCore/Tests/Plugin/PluginHostAPIStructLayoutTests.swift index 768a7f21..1ffaddde 100644 --- a/Packages/OsaurusCore/Tests/Plugin/PluginHostAPIStructLayoutTests.swift +++ b/Packages/OsaurusCore/Tests/Plugin/PluginHostAPIStructLayoutTests.swift @@ -18,8 +18,8 @@ struct PluginHostAPIStructLayoutTests { // From `clang -E` / offsetof against `osaurus_plugin.h` (arm64 Darwin, // standard LP64). The header promises a frozen layout — keep this // pin in lockstep when adding fields. - #expect(MemoryLayout.size == 200) - #expect(MemoryLayout.stride == 200) + #expect(MemoryLayout.size == 224) + #expect(MemoryLayout.stride == 224) #expect(MemoryLayout.alignment == 8) #expect(MemoryLayout.offset(of: \.version) == 0) @@ -27,5 +27,8 @@ struct PluginHostAPIStructLayoutTests { #expect(MemoryLayout.offset(of: \.get_active_agent_id) == 176) #expect(MemoryLayout.offset(of: \.log_structured) == 184) #expect(MemoryLayout.offset(of: \.free_string) == 192) + #expect(MemoryLayout.offset(of: \.register_parser) == 200) + #expect(MemoryLayout.offset(of: \.register_emitter) == 208) + #expect(MemoryLayout.offset(of: \.unregister_format) == 216) } } diff --git a/Packages/OsaurusCore/Tests/Plugin/PluginHostAPITests.swift b/Packages/OsaurusCore/Tests/Plugin/PluginHostAPITests.swift index 3f15ea1c..f4ef4b5a 100644 --- a/Packages/OsaurusCore/Tests/Plugin/PluginHostAPITests.swift +++ b/Packages/OsaurusCore/Tests/Plugin/PluginHostAPITests.swift @@ -331,6 +331,9 @@ struct HostAPIStructTests { #expect(api.list_models == nil) #expect(api.http_request == nil) #expect(api.file_read == nil) + #expect(api.register_parser == nil) + #expect(api.register_emitter == nil) + #expect(api.unregister_format == nil) } @Test func versionFieldCarriesThrough() { @@ -375,9 +378,12 @@ struct HostAPIStructTests { let dummyModels: osr_list_models_t = { nil } let dummyHTTP: osr_http_request_t = { _ in nil } let dummyFileRead: osr_file_read_t = { _ in nil } + let dummyRegisterParser: osr_register_parser_t = { _ in nil } + let dummyRegisterEmitter: osr_register_emitter_t = { _ in nil } + let dummyUnregisterFormat: osr_unregister_format_t = { _ in nil } let api = osr_host_api( - version: 2, + version: 7, config_get: dummyGet, config_set: dummySet, config_delete: dummyDel, @@ -393,7 +399,10 @@ struct HostAPIStructTests { embed: dummyEmbed, list_models: dummyModels, http_request: dummyHTTP, - file_read: dummyFileRead + file_read: dummyFileRead, + register_parser: dummyRegisterParser, + register_emitter: dummyRegisterEmitter, + unregister_format: dummyUnregisterFormat ) #expect(api.config_get != nil) @@ -412,6 +421,9 @@ struct HostAPIStructTests { #expect(api.list_models != nil) #expect(api.http_request != nil) #expect(api.file_read != nil) + #expect(api.register_parser != nil) + #expect(api.register_emitter != nil) + #expect(api.unregister_format != nil) } } diff --git a/Packages/OsaurusCore/Tests/Plugin/PluginHostFreeStringTests.swift b/Packages/OsaurusCore/Tests/Plugin/PluginHostFreeStringTests.swift index 8ccb409b..5bc4296e 100644 --- a/Packages/OsaurusCore/Tests/Plugin/PluginHostFreeStringTests.swift +++ b/Packages/OsaurusCore/Tests/Plugin/PluginHostFreeStringTests.swift @@ -18,12 +18,15 @@ import Testing struct PluginHostFreeStringTests { - @Test func hostStructAdvertisesV6() throws { + @Test func hostStructAdvertisesV7() throws { let ctx = try PluginHostContext(pluginId: "com.test.hostfree.\(UUID())") defer { ctx.teardown() } let api = ctx.buildHostAPI().pointee - #expect(api.version == 6, "v6 ABI must be advertised when host->free_string is wired") + #expect(api.version == 7, "v7 ABI must be advertised when document registration is wired") #expect(api.free_string != nil, "free_string slot must be populated on v6 host") + #expect(api.register_parser != nil, "register_parser slot must be populated on v7 host") + #expect(api.register_emitter != nil, "register_emitter slot must be populated on v7 host") + #expect(api.unregister_format != nil, "unregister_format slot must be populated on v7 host") } @Test func nilPointerIsNoOp() { diff --git a/Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h b/Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h index df37e589..3d1bef32 100644 --- a/Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h +++ b/Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h @@ -1,6 +1,6 @@ // osaurus_plugin.h // -// Osaurus Plugin ABI — current documented surface is v5. +// Osaurus Plugin ABI — current documented surface is v7. // // COMPATIBILITY // ============= @@ -9,9 +9,9 @@ // - osaurus_plugin_entry (v1 — never received the host API) // - osaurus_plugin_entry_v2 (current — receives `osr_host_api*`) // -// New plugins should target v5 by exporting `osaurus_plugin_entry_v2` -// and reading `host->version >= 5`. Plugins compiled against an older -// version (v3 / v4) keep working — `host->version` advertises the +// New plugins should target v7 by exporting `osaurus_plugin_entry_v2` +// and reading `host->version >= 7`. Plugins compiled against an older +// version (v3 / v4 / v5 / v6) keep working — `host->version` advertises the // highest documented surface the host implements; new slots present // on a newer host are simply unused by older plugins. Plugins // compiled against a newer ABI than the host implements should @@ -20,7 +20,8 @@ // // See `docs/plugins/ABI_VERSIONS.md` for the per-version evolution // (v1 base, v2 host injection, v3 streaming cancel, v4 agent -// introspection, v5 structured logging). +// introspection, v5 structured logging, v6 host free, v7 document +// format registration). // // The struct layout is FROZEN — // position of every callback is preserved across versions. Two slots @@ -78,6 +79,7 @@ extern "C" { #define OSR_ABI_VERSION_4 4 #define OSR_ABI_VERSION_5 5 #define OSR_ABI_VERSION_6 6 +#define OSR_ABI_VERSION_7 7 // Opaque context provided by the plugin, passed back to all function calls. typedef void* osr_plugin_ctx_t; @@ -366,6 +368,38 @@ typedef void (*osr_log_structured_fn)(int level, // } typedef void (*osr_host_free_string_fn)(const char* s); +// Document-format registration — plugins call these from `init` to wire +// their parser / emitter into the host's DocumentFormatRegistry. Once +// registered, the host calls back into the plugin via `invoke` with +// `type = "parser"` / `"emitter"` whenever a matching file arrives. +// +// register_parser / register_emitter request_json (required fields): +// { +// "plugin_id": "com.example.myplugin", // owner id for unregistration +// "format_id": "wacky", // matches invoke id +// "extensions": [".wacky", ".wk"], // optional +// "mime_types": ["application/x-wacky"] // optional +// } +// Returns JSON: {"ok": true} or {"ok": false, "error": "..."}. +// +// invoke(type = "parser", id = format_id) payload: +// {"path": "/abs/...", "size_limit": 104857600} +// Plugin returns: +// {"ok": true, "filename": "...", "file_size": N, "text_fallback": "..."} +// {"ok": false, "error": "..."} +// +// invoke(type = "emitter", id = format_id) payload: +// {"destination": "/abs/...", "filename": "...", "text": "..."} +// Plugin returns: {"ok": true} or {"ok": false, "error": "..."}. +// +// unregister_format(format_id_json): {"plugin_id": "...", "format_id": "..."}. +// +// Added in OSR_ABI_VERSION_7. Plugins compiled against earlier ABI +// versions see NULL slots; check `host->version >= 7` before calling. +typedef const char* (*osr_register_parser_fn)(const char* request_json); +typedef const char* (*osr_register_emitter_fn)(const char* request_json); +typedef const char* (*osr_unregister_format_fn)(const char* request_json); + // ── Host API struct (injected into v2+ plugins at init) ── // // The struct layout is FROZEN. Field order and offsets are stable across @@ -373,7 +407,7 @@ typedef void (*osr_host_free_string_fn)(const char* s); // surface the host implements. typedef struct { - uint32_t version; // OSR_ABI_VERSION_6 in current builds + uint32_t version; // OSR_ABI_VERSION_7 in current builds // Config + Storage + Logging osr_config_get_fn config_get; @@ -421,6 +455,11 @@ typedef struct { // plugin's own `free_string`. See typedef comment above for the // backwards-compat fallback to `libc free()` on older hosts. osr_host_free_string_fn free_string; + + // Document-format registration (added in v7) + osr_register_parser_fn register_parser; + osr_register_emitter_fn register_emitter; + osr_unregister_format_fn unregister_format; } osr_host_api; // ── Task lifecycle event types ── diff --git a/docs/plugins/ABI_VERSIONS.md b/docs/plugins/ABI_VERSIONS.md index 99832304..2f622f8e 100644 --- a/docs/plugins/ABI_VERSIONS.md +++ b/docs/plugins/ABI_VERSIONS.md @@ -10,7 +10,7 @@ Three rules the host promises to keep: 1. **Struct layout is frozen.** Field order and offsets in `osr_host_api` and `osr_plugin_api` never change. New callbacks are appended at the end. 2. **Removed callbacks are RESERVED, not deleted.** Two slots (`dispatch_clarify`, `dispatch_add_issue`) remain wired for ABI compatibility but return a structured `not_supported` JSON envelope. Calling them is a no-op safe; new plugins should not invoke them. -3. **Older plugins keep loading.** A plugin compiled against ABI v1 still loads against a v5 host. The plugin sees the v1 subset; the new slots are present but the plugin's struct has no fields for them. +3. **Older plugins keep loading.** A plugin compiled against ABI v1 still loads against a v7 host. The plugin sees the v1 subset; the new slots are present but the plugin's struct has no fields for them. The reverse direction needs a defensive check: a plugin compiled against v5 dlopen'd by a v3 host sees `host->log_structured == NULL`. **Always check before calling a new slot:** @@ -71,6 +71,13 @@ The `host->version` field on `osr_host_api` advertises the highest documented su ``` - Behavior preservation: nothing about what the host returns changed. Existing plugins that already call `libc free()` directly keep working unchanged. +### v7 (`OSR_ABI_VERSION_7`) — document-format registration + +- Host API additions: `register_parser(request_json)`, `register_emitter(request_json)`, and `unregister_format(request_json)`. +- Why it exists: plugins can add specialized document parsers and emitters to the same `DocumentFormatRegistry` lookup path used by in-tree adapters, without changing the app binary for every new file format. +- Contract: registration requests include `plugin_id`, `format_id`, and optional `extensions` / `mime_types`. The host calls back into the plugin with `invoke(type: "parser" | "emitter", id: format_id, payload: ...)`. Responses use `{"ok": true}` or `{"ok": false, "error": "..."}` envelopes. +- Migration: existing plugins do nothing. Plugins that need custom document IO check `host->version >= 7 && host->register_parser` before registering during `init`. + ### Event additions (no struct change) These changes alter `on_task_event` payloads or fire previously-silent slots without bumping `OSR_ABI_VERSION`. The struct layout is untouched, so older plugins keep loading; newer plugins simply add a `case` to their switch. @@ -79,7 +86,7 @@ These changes alter `on_task_event` payloads or fire previously-silent slots wit ## Mirror Struct Audit -Plugins that mirror `osr_host_api` in a non-C language (Swift, Rust, Zig, etc.) MUST keep their mirror byte-identical to the host's frozen layout. The host appends new callbacks to the end of the struct on every version bump (v4: `get_active_agent_id`, v5: `log_structured`, v6: `free_string`); mirrors that drop or reorder a slot dispatch every callback past the mismatch into the wrong host function. +Plugins that mirror `osr_host_api` in a non-C language (Swift, Rust, Zig, etc.) MUST keep their mirror byte-identical to the host's frozen layout. The host appends new callbacks to the end of the struct on every version bump (v4: `get_active_agent_id`, v5: `log_structured`, v6: `free_string`, v7: document registration); mirrors that drop or reorder a slot dispatch every callback past the mismatch into the wrong host function. The classic foot-gun is jumping from a v4 mirror straight to v6 and skipping `log_structured` (v5). The plugin's `host->free_string(ptr)` then resolves to `host->log_structured` (one slot earlier), which discards the pointer. The plugin's *next* host call may also misroute — and the first call that returns a value reads garbage from the wrong slot, eventually crashing inside `libc free()` on a non-malloc pointer (`pointer being freed was not allocated`). @@ -91,7 +98,10 @@ The canonical pin for the layout lives in [`PluginHostAPIStructLayoutTests`](../ | `get_active_agent_id` | 176 | v4 | | `log_structured` | 184 | **v5 — most commonly skipped** | | `free_string` | 192 | v6 | -| (struct stride) | 200 | — | +| `register_parser` | 200 | v7 | +| `register_emitter` | 208 | v7 | +| `unregister_format` | 216 | v7 | +| (struct stride) | 224 | — | ### Pre-flight handshake @@ -109,14 +119,15 @@ If you need one of the above, file an issue — extending the ABI is cheap as lo ## Compatibility table -| Host version | v1 plugins | v2 plugins | v3 plugins | v4 plugins | v5 plugins | v6 plugins | -|---|---|---|---|---|---|---| -| v1 (legacy) | works | won't load | won't load | won't load | won't load | won't load | -| v2 | works | works | missing `complete_cancel` | missing v4 + v3 | missing v4 + v5 + v3 | missing v4 + v5 + v6 + v3 | -| v3 | works | works | works | missing `get_active_agent_id` | missing `log_structured` | missing `free_string` (use `libc free`) | -| v4 | works | works | works | works | missing `log_structured` | missing `free_string` (use `libc free`) | -| v5 | works | works | works | works | works | missing `free_string` (use `libc free`) | -| **v6 (current)** | **works** | **works** | **works** | **works** | **works** | **works** | +| Host version | v1 plugins | v2 plugins | v3 plugins | v4 plugins | v5 plugins | v6 plugins | v7 plugins | +|---|---|---|---|---|---|---|---| +| v1 (legacy) | works | won't load | won't load | won't load | won't load | won't load | won't load | +| v2 | works | works | missing `complete_cancel` | missing v4 + v3 | missing v4 + v5 + v3 | missing v4 + v5 + v6 + v3 | missing v7 + v6 + v5 + v4 + v3 | +| v3 | works | works | works | missing `get_active_agent_id` | missing `log_structured` | missing `free_string` (use `libc free`) | missing v7 + v6 + v5 + v4 | +| v4 | works | works | works | works | missing `log_structured` | missing `free_string` (use `libc free`) | missing v7 + v6 + v5 | +| v5 | works | works | works | works | works | missing `free_string` (use `libc free`) | missing v7 + v6 | +| v6 | works | works | works | works | works | works | missing document registration | +| **v7 (current)** | **works** | **works** | **works** | **works** | **works** | **works** | **works** | "Missing" means the slot is `NULL` on the older host — the newer plugin's defensive `if (host->version >= N && host->callback)` check correctly falls through. diff --git a/docs/plugins/FAQ.md b/docs/plugins/FAQ.md index b53c246e..e82ae738 100644 --- a/docs/plugins/FAQ.md +++ b/docs/plugins/FAQ.md @@ -6,9 +6,9 @@ Quick answers to common questions. For deeper guides see [README.md](README.md). ### Do old plugins still work? -Yes. The Plugin ABI is **frozen** — the `osr_host_api` struct layout never changes; new versions only append optional slots at the end. Plugins compiled against v1 (`osaurus_plugin_entry`, no host API access) through v4 continue to load against the current v5 host unchanged. Two slots (`dispatch_clarify`, `dispatch_add_issue`) are reserved and return `not_supported` envelopes for backwards compat. +Yes. The Plugin ABI is **frozen** — the `osr_host_api` struct layout never changes; new versions only append optional slots at the end. Plugins compiled against v1 (`osaurus_plugin_entry`, no host API access) through v6 continue to load against the current v7 host unchanged. Two slots (`dispatch_clarify`, `dispatch_add_issue`) are reserved and return `not_supported` envelopes for backwards compat. -You only need to rebuild to pick up new callbacks (`complete_cancel` in v3, `get_active_agent_id` in v4, `log_structured` in v5). There is no forced migration. See [ABI_VERSIONS.md](ABI_VERSIONS.md) for the per-version evolution and the `host->version >= N` defensive-check pattern. +You only need to rebuild to pick up new callbacks (`complete_cancel` in v3, `get_active_agent_id` in v4, `log_structured` in v5, `free_string` in v6, document registration in v7). There is no forced migration. See [ABI_VERSIONS.md](ABI_VERSIONS.md) for the per-version evolution and the `host->version >= N` defensive-check pattern. ### What's the difference between native plugins and sandbox plugins? diff --git a/docs/plugins/HOST_API.md b/docs/plugins/HOST_API.md index 9e8ff052..8f520c19 100644 --- a/docs/plugins/HOST_API.md +++ b/docs/plugins/HOST_API.md @@ -1,6 +1,6 @@ # Host API Reference -Reference for the **v6 host API**. Every callback your plugin can invoke is listed here, grouped by category. The canonical C declarations live in `Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h`. Per-version evolution and the defensive-check pattern for older hosts are in [ABI_VERSIONS.md](ABI_VERSIONS.md). +Reference for the **v7 host API**. Every callback your plugin can invoke is listed here, grouped by category. The canonical C declarations live in `Packages/OsaurusCore/Tools/PluginABI/osaurus_plugin.h`. Per-version evolution and the defensive-check pattern for older hosts are in [ABI_VERSIONS.md](ABI_VERSIONS.md). ## Conventions @@ -52,6 +52,9 @@ The canonical pin is [`PluginHostAPIStructLayoutTests`](../../Packages/OsaurusCo | 22 | `get_active_agent_id` | `char* (*)()` | v4 | | 23 | **`log_structured`** | `void (*)(int32_t, const char*, const char*)` | **v5** | | 24 | `free_string` | `void (*)(const char*)` | v6 | +| 25 | `register_parser` | `char* (*)(const char*)` | v7 | +| 26 | `register_emitter` | `char* (*)(const char*)` | v7 | +| 27 | `unregister_format` | `char* (*)(const char*)` | v7 | ### Pinned offsets (Apple Silicon, default alignment) @@ -63,7 +66,10 @@ The struct uses default C alignment — every pointer slot is 8 bytes after `ver | `get_active_agent_id` | 176 | | `log_structured` | 184 | | `free_string` | 192 | -| (struct stride) | 200 | +| `register_parser` | 200 | +| `register_emitter` | 208 | +| `unregister_format` | 216 | +| (struct stride) | 224 | If your mirror disagrees with any of these offsets, your `host->*` calls dispatch into adjacent slots and the host will look wedged or crash. Fix the mirror, don't add defensive checks downstream. @@ -94,6 +100,7 @@ You give up the early misalignment detection if you do this, so prefer to leave - [Dispatch (background tasks)](#dispatch) - [HTTP](#http) - [File I/O](#file-io) +- [Document formats](#document-formats) - [Reserved slots](#reserved-slots) --- @@ -486,6 +493,34 @@ Returns `{"data": "", "size": , "mime_type": "..."}` or an error en --- +## Document formats + +### `register_parser(request_json) -> char*` + +Registers a plugin-backed parser with the host document registry. Check `host->version >= 7 && host->register_parser` before calling. + +```json +{"plugin_id": "com.example.plugin", "format_id": "wacky", "extensions": [".wacky"], "mime_types": ["application/x-wacky"]} +``` + +When a matching document is parsed, the host calls `invoke(type: "parser", id: format_id, payload: ...)` on the plugin. The parser response is `{"ok": true, "filename": "...", "file_size": 123, "text_fallback": "..."}` or `{"ok": false, "error": "..."}`. + +### `register_emitter(request_json) -> char*` + +Registers a plugin-backed emitter for `format_id`. The registration JSON shape matches `register_parser`; emitter routing is based on the document's `format_id`. + +### `unregister_format(request_json) -> char*` + +Removes a plugin-owned format registration. + +```json +{"plugin_id": "com.example.plugin", "format_id": "wacky"} +``` + +All three callbacks return `{"ok": true}` or `{"ok": false, "error": "..."}`. + +--- + ## Reserved slots Two slots are reserved for ABI compatibility. The trampolines return structured `not_supported` envelopes (or void for the void-typed slot) and log an HTTP 410 in Insights. New plugins should not invoke them. diff --git a/docs/plugins/README.md b/docs/plugins/README.md index bdffa1dd..2ec2a685 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -20,7 +20,7 @@ Plugins are macOS dynamic libraries (`.dylib`) that extend Osaurus with new tool ## What you get from the host -Plugins target the **v6 host API surface** (current). Callbacks span: +Plugins target the **v7 host API surface** (current). Callbacks span: - **Config** — read/write per-plugin secrets backed by Keychain (`config_get`, `config_set`, `config_delete`) - **Storage** — per-plugin SQLite database (encrypted at rest, 100 MiB default cap), `db_exec` / `db_query` @@ -29,10 +29,11 @@ Plugins target the **v6 host API surface** (current). Callbacks span: - **Dispatch** — fire-and-forget background tasks (`dispatch`, `task_status`, `dispatch_cancel`, `dispatch_interrupt`, `send_draft`, `list_active_tasks`) - **HTTP** — outbound requests with SSRF protection and a 60 req/min per-(plugin, agent) cap - **File I/O** — read shared artifacts the user has explicitly provided +- **Document formats** — register custom parsers and emitters (`register_parser`, `register_emitter`, `unregister_format`) - **Agent context** — `get_active_agent_id` (v4) for per-agent state keying - **Memory** — `host->free_string` (v6) to release strings the host returned, replacing the previously ambiguous "free with the plugin's `free_string`" path -Older plugins compiled against v1–v5 keep loading; the struct layout is frozen and v6 only appends one new optional slot. See [ABI_VERSIONS.md](ABI_VERSIONS.md) for the per-version evolution and the defensive `host->version >= N` check pattern. +Older plugins compiled against v1–v6 keep loading; the struct layout is frozen and v7 only appends new optional slots. See [ABI_VERSIONS.md](ABI_VERSIONS.md) for the per-version evolution and the defensive `host->version >= N` check pattern. The full reference for each callback lives in [HOST_API.md](HOST_API.md).