Added skills, shortcuts, and bugifixes++

This commit is contained in:
2026-02-18 11:58:45 +01:00
parent 09463d7620
commit 54a8c47df4
24 changed files with 3172 additions and 239 deletions

View File

@@ -0,0 +1,71 @@
//
// AgentSkillFilesService.swift
// oAI
//
// Manages per-skill file directories in Application Support/oAI/skills/<uuid>/
//
import Foundation
import UniformTypeIdentifiers
final class AgentSkillFilesService {
static let shared = AgentSkillFilesService()
private let baseDirectory: URL = {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
return appSupport.appendingPathComponent("oAI/skills", isDirectory: true)
}()
func skillDirectory(for id: UUID) -> URL {
baseDirectory.appendingPathComponent(id.uuidString, isDirectory: true)
}
func ensureDirectory(for id: UUID) {
try? FileManager.default.createDirectory(
at: skillDirectory(for: id), withIntermediateDirectories: true)
}
func listFiles(for id: UUID) -> [URL] {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: skillDirectory(for: id),
includingPropertiesForKeys: [.fileSizeKey],
options: .skipsHiddenFiles)
else { return [] }
return contents.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
func addFile(from sourceURL: URL, to id: UUID) throws {
ensureDirectory(for: id)
let dest = skillDirectory(for: id).appendingPathComponent(sourceURL.lastPathComponent)
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.copyItem(at: sourceURL, to: dest)
}
func deleteFile(at url: URL) {
try? FileManager.default.removeItem(at: url)
}
func deleteAll(for id: UUID) {
try? FileManager.default.removeItem(at: skillDirectory(for: id))
}
func hasFiles(for id: UUID) -> Bool {
!listFiles(for: id).isEmpty
}
/// Returns (filename, content) for all readable text files
func readTextFiles(for id: UUID) -> [(name: String, content: String)] {
listFiles(for: id).compactMap { url in
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return (url.lastPathComponent, content)
}
}
/// Returns file size in bytes, or nil if unavailable
func fileSize(at url: URL) -> Int? {
(try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
}
}

View File

@@ -0,0 +1,542 @@
//
// AnytypeMCPService.swift
// oAI
//
// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009
//
import Foundation
import os
@Observable
class AnytypeMCPService {
static let shared = AnytypeMCPService()
private let settings = SettingsService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "mcp")
private let apiVersion = "2025-11-08"
private let timeout: TimeInterval = 10
private(set) var isConnected = false
private init() {}
// MARK: - Connection Test
func testConnection() async -> Result<String, Error> {
do {
let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil)
if let spaces = result["data"] as? [[String: Any]] {
isConnected = true
return .success("Connected (\(spaces.count) space\(spaces.count == 1 ? "" : "s"))")
} else {
isConnected = true
return .success("Connected to Anytype")
}
} catch {
isConnected = false
return .failure(error)
}
}
// MARK: - Tool Schemas
func getToolSchemas() -> [Tool] {
return [
makeTool(
name: "anytype_search_global",
description: "Search across all Anytype spaces for objects matching a query. Returns matching objects with their IDs, names, types, and space info.",
properties: [
"query": prop("string", "The search query text"),
"limit": prop("number", "Maximum number of results to return (default: 20)")
],
required: ["query"]
),
makeTool(
name: "anytype_list_spaces",
description: "List all available Anytype spaces (workspaces). Returns space IDs, names, and basic info.",
properties: [:],
required: []
),
makeTool(
name: "anytype_get_space_objects",
description: "Get objects in a specific Anytype space. Returns a list of objects with their IDs, names, and types. Use search tools to find specific objects rather than listing all.",
properties: [
"space_id": prop("string", "The ID of the Anytype space"),
"limit": prop("number", "Maximum number of objects to return (default: 20, max recommended: 50)")
],
required: ["space_id"]
),
makeTool(
name: "anytype_get_object",
description: "Get the full details and content of a specific Anytype object by ID. The 'body' field contains the COMPLETE markdown including Anytype internal links (anytype://object?...) and file links that MUST be preserved exactly when updating.",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object to retrieve")
],
required: ["space_id", "object_id"]
),
makeTool(
name: "anytype_create_object",
description: "Create a new object (note, task, or page) in an Anytype space.",
properties: [
"space_id": prop("string", "The ID of the space to create the object in"),
"name": prop("string", "The name/title of the object"),
"body": prop("string", "The text content/body of the object"),
"type": prop("string", "The type of object: 'note', 'task', or 'page' (default: note)")
],
required: ["space_id", "name"]
),
makeTool(
name: "anytype_update_object",
description: """
Replace the full markdown body or rename an Anytype object. \
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
it is safer and does not risk modifying other content. \
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
CRITICAL RULES when using this tool: \
1) Always call anytype_get_object first to get the current EXACT markdown. \
2) Make ONLY the minimal requested change — nothing else. \
3) Copy ALL other content CHARACTER-FOR-CHARACTER, including anytype://object?objectId=... links, \
file links, headings, and all formatting. Do NOT summarise, paraphrase, or reformat anything you are not asked to change.
""",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object to update"),
"name": prop("string", "New name/title for the object (optional)"),
"body": prop("string", "COMPLETE replacement markdown content — must preserve ALL existing content with ONLY the minimal intended change applied")
],
required: ["space_id", "object_id"]
),
makeTool(
name: "anytype_set_done",
description: "Mark a task as done or undone in Anytype by setting its 'done' relation. This is the correct way to check/uncheck tasks — do not use anytype_update_object body text for this.",
properties: [
"space_id": prop("string", "The ID of the space containing the task"),
"object_id": prop("string", "The ID of the task to update"),
"done": prop("boolean", "true to mark as done, false to mark as undone")
],
required: ["space_id", "object_id", "done"]
),
makeTool(
name: "anytype_toggle_checkbox",
description: """
Surgically toggle a specific checkbox in an Anytype object without rewriting the full body. \
Use this INSTEAD of anytype_update_object when marking a to-do item done/undone. \
Provide partial text that uniquely identifies the checkbox line (do not include the '- [ ]' or '- [x]' prefix). \
The tool finds the matching line and flips its checkbox state, preserving all other content exactly.
""",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object containing the checkbox"),
"line_text": prop("string", "Partial text of the checkbox line to find (e.g. 'Buy groceries'). Do NOT include the '- [ ]' prefix."),
"done": prop("boolean", "true to check the box, false to uncheck it")
],
required: ["space_id", "object_id", "line_text", "done"]
),
makeTool(
name: "anytype_search_space",
description: "Search within a specific Anytype space for objects matching a query.",
properties: [
"space_id": prop("string", "The ID of the Anytype space to search in"),
"query": prop("string", "The search query text"),
"limit": prop("number", "Maximum number of results (default: 20)")
],
required: ["space_id", "query"]
)
]
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
log.info("Executing Anytype tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON"]
}
return await executeToolAsync(name: name, args: args)
}
private func executeToolAsync(name: String, args: [String: Any]) async -> [String: Any] {
do {
switch name {
case "anytype_search_global":
guard let query = args["query"] as? String else {
return ["error": "Missing required parameter: query"]
}
let limit = args["limit"] as? Int ?? 20
return try await searchGlobal(query: query, limit: limit)
case "anytype_list_spaces":
return try await listSpaces()
case "anytype_get_space_objects":
guard let spaceId = args["space_id"] as? String else {
return ["error": "Missing required parameter: space_id"]
}
let limit = min(args["limit"] as? Int ?? 20, 50)
return try await getSpaceObjects(spaceId: spaceId, limit: limit)
case "anytype_get_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
return try await getObject(spaceId: spaceId, objectId: objectId)
case "anytype_create_object":
guard let spaceId = args["space_id"] as? String,
let name = args["name"] as? String else {
return ["error": "Missing required parameters: space_id, name"]
}
let body = args["body"] as? String ?? ""
let type_ = args["type"] as? String ?? "note"
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
case "anytype_update_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
let name = args["name"] as? String
let body = args["body"] as? String
return try await updateObject(spaceId: spaceId, objectId: objectId, name: name, body: body)
case "anytype_set_done":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
// Accept done as Bool or as Int (1/0) since JSON can arrive either way
let done: Bool
if let b = args["done"] as? Bool {
done = b
} else if let n = args["done"] as? Int {
done = n != 0
} else {
return ["error": "Missing or invalid parameter: done (expected boolean)"]
}
return try await setDone(spaceId: spaceId, objectId: objectId, done: done)
case "anytype_toggle_checkbox":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String,
let lineText = args["line_text"] as? String else {
return ["error": "Missing required parameters: space_id, object_id, line_text"]
}
let done: Bool
if let b = args["done"] as? Bool { done = b }
else if let n = args["done"] as? Int { done = n != 0 }
else { return ["error": "Missing or invalid parameter: done (expected boolean)"] }
return try await toggleCheckbox(spaceId: spaceId, objectId: objectId, lineText: lineText, done: done)
case "anytype_search_space":
guard let spaceId = args["space_id"] as? String,
let query = args["query"] as? String else {
return ["error": "Missing required parameters: space_id, query"]
}
let limit = args["limit"] as? Int ?? 20
return try await searchSpace(spaceId: spaceId, query: query, limit: limit)
default:
return ["error": "Unknown Anytype tool: \(name)"]
}
} catch AnytypeError.notRunning {
return ["error": "Cannot connect to Anytype. Make sure the desktop app is running."]
} catch AnytypeError.unauthorized {
return ["error": "Invalid API key. Check your Anytype API key in Settings > MCP."]
} catch AnytypeError.httpError(let code, let msg) {
return ["error": "Anytype API error \(code): \(msg)"]
} catch {
return ["error": "Anytype error: \(error.localizedDescription)"]
}
}
// MARK: - API Operations
private func searchGlobal(query: String, limit: Int) async throws -> [String: Any] {
let body: [String: Any] = ["query": query, "limit": limit]
let result = try await request(endpoint: "/v1/search", method: "POST", body: body)
if let objects = result["data"] as? [[String: Any]] {
let formatted = objects.map { formatObject($0) }
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": [], "message": "No results found"]
}
private func listSpaces() async throws -> [String: Any] {
let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil)
if let spaces = result["data"] as? [[String: Any]] {
let formatted = spaces.map { space -> [String: Any] in
[
"id": space["id"] ?? "",
"name": space["name"] ?? "Unnamed Space",
"type": space["spaceType"] ?? "unknown"
]
}
return ["count": formatted.count, "spaces": formatted]
}
return ["count": 0, "spaces": []]
}
private func getSpaceObjects(spaceId: String, limit: Int) async throws -> [String: Any] {
let result = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects",
method: "GET",
body: nil,
queryParams: ["limit": String(limit)]
)
if let objects = result["data"] as? [[String: Any]] {
let formatted = Array(objects.prefix(limit).map { formatObject($0) })
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": []]
}
private func getObject(spaceId: String, objectId: String) async throws -> [String: Any] {
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
if let object = result["object"] as? [String: Any] {
return formatObjectDetail(object)
}
// Return full raw response if expected structure not found
return result
}
private func createObject(spaceId: String, name: String, body: String, type: String) async throws -> [String: Any] {
let typeKey: String
switch type.lowercased() {
case "task": typeKey = "ot-task"
case "page": typeKey = "ot-page"
default: typeKey = "ot-note"
}
let requestBody: [String: Any] = [
"name": name,
"body": body, // some API versions use "body"
"markdown": body, // newer API versions use "markdown"
"typeKey": typeKey,
"template_id": ""
]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects", method: "POST", body: requestBody)
if let object = result["object"] as? [String: Any] {
return ["success": true, "id": object["id"] ?? "", "name": name, "message": "Object created successfully"]
}
return ["success": true, "message": "Object created"]
}
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
var requestBody: [String: Any] = [:]
if let name = name { requestBody["name"] = name }
// The Anytype API GET response uses "markdown" for body content, not "body"
if let body = body { requestBody["markdown"] = body }
guard !requestBody.isEmpty else {
return ["error": "No fields to update. Provide name or body."]
}
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody)
if result.isEmpty {
return ["success": true, "message": "Object updated (empty response from Anytype API)"]
}
return result
}
private func toggleCheckbox(spaceId: String, objectId: String, lineText: String, done: Bool) async throws -> [String: Any] {
// Fetch current content
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
guard let object = result["object"] as? [String: Any] else {
return ["error": "Object not found"]
}
let markdown: String
if let md = object["markdown"] as? String { markdown = md }
else if let body = object["body"] as? String { markdown = body }
else { return ["error": "Object has no markdown body"] }
// Find the line containing the search text (case-insensitive)
let lines = markdown.components(separatedBy: "\n")
let searchLower = lineText.lowercased()
guard let idx = lines.firstIndex(where: {
let stripped = $0.replacingOccurrences(of: "- [ ] ", with: "")
.replacingOccurrences(of: "- [x] ", with: "")
.replacingOccurrences(of: "- [X] ", with: "")
return stripped.lowercased().contains(searchLower)
}) else {
return ["error": "No checkbox line found containing: '\(lineText)'"]
}
var line = lines[idx]
let wasDone = line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ")
if done == wasDone {
return ["success": true, "message": "Checkbox was already \(done ? "checked" : "unchecked")", "line": line]
}
// Flip the checkbox marker, preserving the rest of the line exactly
if done {
if line.hasPrefix("- [ ] ") { line = "- [x] " + line.dropFirst(6) }
} else {
if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") { line = "- [ ] " + line.dropFirst(6) }
}
var updated = lines
updated[idx] = line
let newMarkdown = updated.joined(separator: "\n")
let patchResult = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
method: "PATCH",
body: ["markdown": newMarkdown]
)
return ["success": true, "done": done, "line": line, "message": "Checkbox updated"]
}
private func setDone(spaceId: String, objectId: String, done: Bool) async throws -> [String: Any] {
// Anytype stores task completion as a relation called "done"
let requestBody: [String: Any] = [
"properties": [
"done": done
]
]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody)
if result.isEmpty {
return ["success": true, "done": done, "message": "setDone sent (empty response from Anytype API — verify in app)"]
}
return result
}
private func searchSpace(spaceId: String, query: String, limit: Int) async throws -> [String: Any] {
let body: [String: Any] = ["query": query, "limit": limit]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/search", method: "POST", body: body)
if let objects = result["data"] as? [[String: Any]] {
let formatted = objects.map { formatObject($0) }
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": [], "message": "No results found"]
}
// MARK: - HTTP Client
private func request(endpoint: String, method: String, body: [String: Any]?, queryParams: [String: String] = [:]) async throws -> [String: Any] {
guard let apiKey = settings.anytypeMcpAPIKey, !apiKey.isEmpty else {
throw AnytypeError.unauthorized
}
let baseURL = settings.anytypeMcpEffectiveURL
var urlString = baseURL + endpoint
if !queryParams.isEmpty {
var comps = URLComponents(string: urlString) ?? URLComponents()
comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
urlString = comps.url?.absoluteString ?? urlString
}
guard let url = URL(string: urlString) else {
throw AnytypeError.httpError(0, "Invalid URL: \(urlString)")
}
var urlRequest = URLRequest(url: url, timeoutInterval: timeout)
urlRequest.httpMethod = method
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue(apiVersion, forHTTPHeaderField: "Anytype-Version")
if let body = body, method != "GET" {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw AnytypeError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 {
throw AnytypeError.unauthorized
}
guard (200...299).contains(httpResponse.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw AnytypeError.httpError(httpResponse.statusCode, msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return json
} catch let error as AnytypeError {
throw error
} catch {
// Connection refused or network error = Anytype not running
throw AnytypeError.notRunning
}
}
// MARK: - Helpers
private func formatObject(_ obj: [String: Any]) -> [String: Any] {
[
"id": obj["id"] ?? "",
"name": obj["name"] ?? "Unnamed",
"type": obj["type"] ?? obj["objectType"] ?? "unknown",
"space_id": obj["spaceId"] ?? ""
]
}
private func formatObjectDetail(_ obj: [String: Any]) -> [String: Any] {
var result: [String: Any] = formatObject(obj)
// Anytype API returns content as "markdown" not "body"
if let markdown = obj["markdown"] as? String { result["body"] = markdown }
else if let body = obj["body"] as? String { result["body"] = body }
if let snippet = obj["snippet"] as? String { result["snippet"] = snippet }
return result
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
}
// MARK: - Error Types
enum AnytypeError: LocalizedError {
case notRunning
case unauthorized
case httpError(Int, String)
var errorDescription: String? {
switch self {
case .notRunning:
return "Cannot connect to Anytype. Make sure the desktop app is running."
case .unauthorized:
return "Invalid API key. Check your Anytype API key in Settings > MCP."
case .httpError(let code, let msg):
return "Anytype API error \(code): \(msg)"
}
}
}

View File

@@ -463,7 +463,7 @@ final class DatabaseService: Sendable {
.filter(Column("conversationId") == record.id)
.fetchCount(db)) ?? 0
// Get last message date
// Get last message (for date + model fallback)
let lastMsg = try? MessageRecord
.filter(Column("conversationId") == record.id)
.order(Column("sortOrder").desc)
@@ -471,15 +471,18 @@ final class DatabaseService: Sendable {
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
// Create conversation with empty messages array but correct metadata
var conv = Conversation(
id: id,
name: record.name,
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
createdAt: createdAt,
updatedAt: lastDate
updatedAt: lastDate,
primaryModel: primaryModel
)
// We store placeholder messages just for the count; lastMessageDate uses updatedAt
conv.updatedAt = lastDate
return conv
}

View File

@@ -130,31 +130,37 @@ final class EmailService {
// Search for ALL unseen emails first
let allUnseenUIDs = try await client.searchUnseen()
// Remove UIDs that are no longer unseen (emails were deleted/marked read)
checkedUIDs = checkedUIDs.intersection(Set(allUnseenUIDs))
// Check each email for the subject identifier
for uid in allUnseenUIDs {
if !checkedUIDs.contains(uid) {
checkedUIDs.insert(uid)
// Skip if we've already checked this UID (for non-matching emails only)
if checkedUIDs.contains(uid) {
continue
}
do {
let email = try await client.fetchEmail(uid: uid)
do {
let email = try await client.fetchEmail(uid: uid)
// Check if email has the correct subject identifier
if email.subject.contains(settings.emailSubjectIdentifier) {
// Valid email - process it
log.info("New email found: \(email.subject) from \(email.from)")
// Check if email has the correct subject identifier
if email.subject.contains(self.settings.emailSubjectIdentifier) {
// Valid email - process it (don't add to checkedUIDs, handler will delete it)
log.info("Found matching email: \(email.subject) from \(email.from)")
// Call callback on main thread
await MainActor.run {
onNewEmail?(email)
}
} else {
// Wrong subject - delete it
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
try await client.deleteEmail(uid: uid)
// Call callback on main thread
await MainActor.run {
onNewEmail?(email)
}
} catch {
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
} else {
// Wrong subject - delete it and remember we checked it
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
try await client.deleteEmail(uid: uid)
checkedUIDs.insert(uid) // Only track non-matching emails
}
} catch {
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
checkedUIDs.insert(uid) // Track failed emails to avoid retry loops
}
}

View File

@@ -16,6 +16,12 @@ class GitSyncService {
// Debounce tracking
private var pendingSyncTask: Task<Void, Never>?
private init() {
// Check if repository is cloned at initialization (synchronous check)
let localPath = expandPath(settings.syncLocalPath)
syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")
}
// MARK: - Repository Operations
/// Test connection to remote repository
@@ -366,9 +372,17 @@ class GitSyncService {
/// Sync on app startup (pull + import only, no push)
/// Runs silently in background to fetch changes from other devices
func syncOnStartup() async {
// First, update status to check if repo is actually cloned
await updateStatus()
// Only run if configured and cloned
guard settings.syncConfigured && syncStatus.isCloned else {
log.debug("Skipping startup sync (not configured or not cloned)")
guard settings.syncConfigured else {
log.debug("Skipping startup sync (sync not configured)")
return
}
guard syncStatus.isCloned else {
log.debug("Skipping startup sync (repository not cloned)")
return
}

View File

@@ -91,6 +91,8 @@ class MCPService {
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
@@ -189,6 +191,11 @@ class MCPService {
))
}
// Add Anytype tools if enabled and configured
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
tools.append(contentsOf: anytypeService.getToolSchemas())
}
return tools
}
@@ -213,7 +220,7 @@ class MCPService {
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) -> [String: Any] {
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
@@ -303,6 +310,10 @@ class MCPService {
return copyFile(source: source, destination: destination)
default:
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}

View File

@@ -23,6 +23,7 @@ class SettingsService {
static let openaiAPIKey = "openaiAPIKey"
static let googleAPIKey = "googleAPIKey"
static let googleSearchEngineID = "googleSearchEngineID"
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
}
// Old keychain keys (for migration only)
@@ -313,6 +314,120 @@ class SettingsService {
}
}
// MARK: - User Shortcuts (prompt template macros)
var userShortcuts: [Shortcut] {
get {
guard let json = cache["userShortcuts"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([Shortcut].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["userShortcuts"] = json
DatabaseService.shared.setSetting(key: "userShortcuts", value: json)
}
}
}
func addShortcut(_ shortcut: Shortcut) { userShortcuts = userShortcuts + [shortcut] }
func updateShortcut(_ shortcut: Shortcut) {
userShortcuts = userShortcuts.map { $0.id == shortcut.id ? shortcut : $0 }
}
func deleteShortcut(id: UUID) { userShortcuts = userShortcuts.filter { $0.id != id } }
// MARK: - Agent Skills (SKILL.md-style behavioral instructions)
var agentSkills: [AgentSkill] {
get {
guard let json = cache["agentSkills"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([AgentSkill].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["agentSkills"] = json
DatabaseService.shared.setSetting(key: "agentSkills", value: json)
}
}
}
func addAgentSkill(_ skill: AgentSkill) { agentSkills = agentSkills + [skill] }
func updateAgentSkill(_ skill: AgentSkill) {
agentSkills = agentSkills.map { $0.id == skill.id ? skill : $0 }
}
func deleteAgentSkill(id: UUID) {
agentSkills = agentSkills.filter { $0.id != id }
AgentSkillFilesService.shared.deleteAll(for: id)
}
func toggleAgentSkill(id: UUID) {
agentSkills = agentSkills.map { s in
s.id == id ? AgentSkill(id: s.id, name: s.name, skillDescription: s.skillDescription,
content: s.content, isActive: !s.isActive,
createdAt: s.createdAt, updatedAt: Date()) : s
}
}
// MARK: - Anytype MCP Settings
var anytypeMcpEnabled: Bool {
get { cache["anytypeMcpEnabled"] == "true" }
set {
cache["anytypeMcpEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "anytypeMcpEnabled", value: String(newValue))
}
}
var anytypeMcpURL: String {
get { cache["anytypeMcpURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "anytypeMcpURL")
DatabaseService.shared.deleteSetting(key: "anytypeMcpURL")
} else {
cache["anytypeMcpURL"] = trimmed
DatabaseService.shared.setSetting(key: "anytypeMcpURL", value: trimmed)
}
}
}
var anytypeMcpEffectiveURL: String {
let url = anytypeMcpURL
return url.isEmpty ? "http://127.0.0.1:31009" : url
}
var anytypeMcpAPIKey: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey)
}
}
}
var anytypeMcpConfigured: Bool {
guard let key = anytypeMcpAPIKey else { return false }
return !key.isEmpty
}
// MARK: - Search Settings
var searchProvider: Settings.SearchProvider {