Skip to content

Commit 122dbf8

Browse files
committed
Ship afm-server 1.1.0 beta 3
1 parent 868809e commit 122dbf8

15 files changed

Lines changed: 2419 additions & 1386 deletions

README.md

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Built by [Techopolis](https://techopolisonline.com).
1515
- **Guardrail Recovery**: Automatic session eviction on safety violations prevents refusal spirals — a single bad message does not poison the entire conversation
1616
- **Soft Refusal Detection**: Detects when the model returns a refusal as normal text (not an exception) and resets the session to keep follow-up messages working
1717
- **Concurrency Control**: Configurable semaphore limits concurrent inference calls (default 3) with FIFO queuing for additional requests
18-
- **Tool Calling**: File system tools (read, write, edit, delete, move, list directory, create directory, check path)
18+
- **Tool Calling**: OpenAI-compatible delegated tool calls for agent harnesses, plus server-local file and terminal tools for built-in/local requests
1919
- **Auto-Updates**: Sparkle 2 checks for updates daily and shows a dock badge when one is available
2020
- **Privacy First**: All processing happens on-device — no data leaves your Mac
2121

@@ -358,7 +358,7 @@ For comparison, you can also configure cloud-hosted models in Xcode 26 Intellige
358358

359359
Visit [OpenRouter.ai](https://openrouter.ai) to get an API key that provides access to models from Anthropic, OpenAI, Google, and others through a single endpoint.
360360

361-
However, afm-server offers the advantage of completely local, private AI assistance without requiring API keys or incurring usage costs.
361+
However, afm-server offers the advantage of completely local, private AI assistance without cloud API keys or usage costs. The local server still uses a user-configurable API key to protect its loopback endpoints.
362362

363363
### Cursor IDE
364364

@@ -392,26 +392,38 @@ Any application that supports custom OpenAI API endpoints can use afm-server:
392392

393393
- Set the API base URL to `http://127.0.0.1:11435/v1`
394394
- Use `apple.local` as the model name
395-
- API key is not required (but can be set to any value if the client requires it)
395+
- Set the client API key to the value shown in afm-server Settings or the dashboard API Key card
396+
- afm-server accepts that key as `Authorization: Bearer <API key>`
396397

397398
## Tool Calling
398399

399-
The server supports tool calling for file operations within a sandboxed workspace:
400+
afm-server supports two tool paths:
400401

401-
### Available Tools
402+
### Client-Delegated Tools
402403

403-
- `read_file`: Read file contents
404-
- `write_file`: Create or write content to a file
405-
- `edit_file`: Modify a file by replacing text
406-
- `delete_file`: Remove a file
407-
- `move_file`: Move or rename a file
408-
- `list_directory`: List directory contents
409-
- `create_directory`: Create new directories
410-
- `check_path`: Check if a path exists and get its type
404+
When an OpenAI-compatible client sends a `tools` array to `POST /v1/chat/completions`, afm-server behaves like a model backend. It can return an assistant message with `tool_calls` and `finish_reason: "tool_calls"`. The connecting client, such as Pi, Hermes, OpenClaw, or another coding harness, is responsible for executing those tools on the client machine and sending the results back as `role: "tool"` messages.
405+
406+
This is the correct path for agentic coding work because the harness owns the workspace, terminal, approvals, and tool registry.
407+
408+
Streaming requests also emit OpenAI-style `delta.tool_calls` chunks so clients that keep `stream: true` can still see tool calls.
409+
410+
### Server-Local Tools
411+
412+
When no client tools are provided, afm-server can use Apple Foundation Models native Swift tools for local file and terminal requests. These tools run inside the afm-server process on the Mac hosting the server:
413+
414+
- `bash`: Execute a short shell command on the server Mac
415+
- `read_file`: Read file contents on the server Mac
416+
- `write_file`: Create or write content to a file on the server Mac
417+
- `edit_file`: Modify a file by replacing text on the server Mac
418+
- `delete_file`: Remove a file on the server Mac
419+
- `move_file`: Move or rename a file on the server Mac
420+
- `list_directory`: List directory contents on the server Mac
421+
- `create_directory`: Create new directories on the server Mac
422+
- `check_path`: Check if a path exists and get its type on the server Mac
411423

412424
### Workspace Configuration
413425

414-
Set the `PI_WORKSPACE_ROOT` environment variable to specify the root directory for file operations. If not set, it defaults to your Documents folder.
426+
Set the `PI_WORKSPACE_ROOT` environment variable to specify the root directory for server-local file operations. If not set, it defaults to your Documents folder.
415427

416428
```bash
417429
export PI_WORKSPACE_ROOT=/path/to/your/workspace

afm-server-1.1.0-beta.3.dmg

2.23 MB
Binary file not shown.

afm-server-1.1.0-beta.3.zip

1.84 MB
Binary file not shown.

afm-server.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@
270270
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
271271
CODE_SIGN_ENTITLEMENTS = "afm-server/afm-server.entitlements";
272272
CODE_SIGN_STYLE = Automatic;
273-
CURRENT_PROJECT_VERSION = 2;
273+
CURRENT_PROJECT_VERSION = 3;
274274
DEVELOPMENT_TEAM = M9SNPTXA44;
275275
ENABLE_APP_SANDBOX = YES;
276276
ENABLE_HARDENED_RUNTIME = YES;
@@ -295,7 +295,7 @@
295295
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
296296
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
297297
MACOSX_DEPLOYMENT_TARGET = 26.0;
298-
MARKETING_VERSION = 1.1.0-beta.2;
298+
MARKETING_VERSION = 1.1.0-beta.3;
299299
PRODUCT_BUNDLE_IDENTIFIER = "online.techopolis.afm-server";
300300
PRODUCT_NAME = "$(TARGET_NAME)";
301301
REGISTER_APP_GROUPS = YES;
@@ -318,7 +318,7 @@
318318
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
319319
CODE_SIGN_ENTITLEMENTS = "afm-server/afm-server.entitlements";
320320
CODE_SIGN_STYLE = Automatic;
321-
CURRENT_PROJECT_VERSION = 2;
321+
CURRENT_PROJECT_VERSION = 3;
322322
DEVELOPMENT_TEAM = M9SNPTXA44;
323323
ENABLE_APP_SANDBOX = YES;
324324
ENABLE_HARDENED_RUNTIME = YES;
@@ -343,7 +343,7 @@
343343
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
344344
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
345345
MACOSX_DEPLOYMENT_TARGET = 26.0;
346-
MARKETING_VERSION = 1.1.0-beta.2;
346+
MARKETING_VERSION = 1.1.0-beta.3;
347347
PRODUCT_BUNDLE_IDENTIFIER = "online.techopolis.afm-server";
348348
PRODUCT_NAME = "$(TARGET_NAME)";
349349
REGISTER_APP_GROUPS = YES;

afm-server/AFMServerApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ struct AFMServerApp: App {
7878
#endif
7979

8080
init() {
81-
// Server auto-starts on launch via the MenuBarExtra .task modifier
81+
// Server auto-starts on launch via ServerController.
8282
}
8383

8484
var body: some Scene {

afm-server/AppLogStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ final class AppLogStore: ObservableObject {
7676
}
7777
}
7878

79-
enum AppLog {
79+
nonisolated enum AppLog {
8080
private static let logger = Logger(subsystem: "online.techopolis.afm-server", category: "AppLog")
8181

8282
static func debug(_ message: String, source: String = "app") {

afm-server/FileTools.swift

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
import Foundation
99
import OSLog
10+
#if canImport(Darwin)
11+
import Darwin
12+
#endif
1013

1114
#if canImport(FoundationModels)
1215
import FoundationModels
@@ -19,7 +22,7 @@ import FoundationModels
1922
nonisolated final class FileToolsManager: @unchecked Sendable {
2023
static let shared = FileToolsManager()
2124

22-
private let logger = Logger(subsystem: "com.example.PerspectiveIntelligence", category: "FileTools")
25+
private let logger = Logger(subsystem: "online.techopolis.afm-server", category: "FileTools")
2326
private let fm = FileManager.default
2427

2528
/// The allowed root directories for file operations.
@@ -76,6 +79,25 @@ nonisolated final class FileToolsManager: @unchecked Sendable {
7679
/// Resolves a path to an absolute URL, handling ~ expansion and relative paths
7780
func resolvePath(_ path: String) -> URL {
7881
var resolvedPath = path
82+
let trimmedPath = resolvedPath.trimmingCharacters(in: .whitespacesAndNewlines)
83+
let lowerPath = trimmedPath.lowercased()
84+
let developerExactMatches = ["developer", "~/developer", "~/desktop/developer"]
85+
let developerPrefixes = ["developer/", "~/developer/", "~/desktop/developer/"]
86+
87+
for prefix in developerExactMatches where lowerPath == prefix {
88+
let developerURL = fm.homeDirectoryForCurrentUser.appendingPathComponent("Developer")
89+
return developerURL.standardized
90+
}
91+
92+
for prefix in developerPrefixes where lowerPath.hasPrefix(prefix) {
93+
let suffixStart = trimmedPath.index(trimmedPath.startIndex, offsetBy: min(prefix.count, trimmedPath.count))
94+
let suffix = String(trimmedPath[suffixStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
95+
var developerURL = fm.homeDirectoryForCurrentUser.appendingPathComponent("Developer")
96+
if !suffix.isEmpty {
97+
developerURL.appendPathComponent(suffix)
98+
}
99+
return developerURL.standardized
100+
}
79101

80102
// Expand ~ to home directory
81103
if resolvedPath.hasPrefix("~/") {
@@ -447,6 +469,106 @@ nonisolated struct PathCheckResult: Codable, Sendable {
447469
let size: Int?
448470
}
449471

472+
nonisolated struct TerminalCommandResult: Codable, Sendable {
473+
let command: String
474+
let workingDirectory: String
475+
let exitCode: Int32?
476+
let timedOut: Bool
477+
let durationSeconds: Double
478+
let output: String
479+
let truncated: Bool
480+
let fullOutputPath: String?
481+
}
482+
483+
nonisolated enum TerminalCommandRunner {
484+
private static let maxOutputBytes = 64 * 1024
485+
486+
static func run(command: String, workingDirectory: URL, timeoutSeconds: Int) async throws -> TerminalCommandResult {
487+
try await Task.detached(priority: .utility) {
488+
let timeout = min(max(timeoutSeconds, 1), 120)
489+
let fm = FileManager.default
490+
let outputURL = fm.temporaryDirectory
491+
.appendingPathComponent("afm-terminal-\(UUID().uuidString).log")
492+
493+
fm.createFile(atPath: outputURL.path, contents: nil)
494+
let outputHandle = try FileHandle(forWritingTo: outputURL)
495+
defer {
496+
try? outputHandle.close()
497+
}
498+
499+
let process = Process()
500+
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
501+
process.arguments = ["-lc", command]
502+
process.currentDirectoryURL = workingDirectory
503+
process.standardOutput = outputHandle
504+
process.standardError = outputHandle
505+
506+
let startedAt = Date()
507+
try process.run()
508+
509+
let deadline = Date().addingTimeInterval(TimeInterval(timeout))
510+
var timedOut = false
511+
512+
while process.isRunning {
513+
if Date() >= deadline {
514+
timedOut = true
515+
process.terminate()
516+
break
517+
}
518+
try await Task.sleep(nanoseconds: 50_000_000)
519+
}
520+
521+
if timedOut {
522+
let killDeadline = Date().addingTimeInterval(2)
523+
while process.isRunning && Date() < killDeadline {
524+
try await Task.sleep(nanoseconds: 50_000_000)
525+
}
526+
#if canImport(Darwin)
527+
if process.isRunning {
528+
kill(process.processIdentifier, SIGKILL)
529+
}
530+
#endif
531+
}
532+
533+
process.waitUntilExit()
534+
try? outputHandle.close()
535+
536+
let duration = Date().timeIntervalSince(startedAt)
537+
let attrs = try? fm.attributesOfItem(atPath: outputURL.path)
538+
let fileSize = attrs?[.size] as? UInt64 ?? 0
539+
let truncated = fileSize > UInt64(maxOutputBytes)
540+
let outputData: Data
541+
542+
let readHandle = try FileHandle(forReadingFrom: outputURL)
543+
defer {
544+
try? readHandle.close()
545+
}
546+
547+
if truncated {
548+
try readHandle.seek(toOffset: fileSize - UInt64(maxOutputBytes))
549+
outputData = readHandle.readDataToEndOfFile()
550+
} else {
551+
outputData = readHandle.readDataToEndOfFile()
552+
try? fm.removeItem(at: outputURL)
553+
}
554+
555+
let output = String(data: outputData, encoding: .utf8)
556+
?? String(decoding: outputData, as: UTF8.self)
557+
558+
return TerminalCommandResult(
559+
command: command,
560+
workingDirectory: workingDirectory.path,
561+
exitCode: timedOut ? nil : process.terminationStatus,
562+
timedOut: timedOut,
563+
durationSeconds: duration,
564+
output: output.isEmpty ? "(no output)" : output,
565+
truncated: truncated,
566+
fullOutputPath: truncated ? outputURL.path : nil
567+
)
568+
}.value
569+
}
570+
}
571+
450572
// MARK: - Foundation Models Tool Definitions
451573

452574
#if canImport(FoundationModels)
@@ -492,7 +614,7 @@ nonisolated struct WriteFileTool: Tool {
492614

493615
func call(arguments: Arguments) async throws -> String {
494616
// Log that the tool was called
495-
let logger = Logger(subsystem: "com.example.PerspectiveIntelligence", category: "WriteFileTool")
617+
let logger = Logger(subsystem: "online.techopolis.afm-server", category: "WriteFileTool")
496618
logger.log("[WriteFileTool] CALLED with path=\(arguments.path, privacy: .public)")
497619

498620
let result = try FileToolsManager.shared.writeFile(
@@ -594,11 +716,11 @@ nonisolated struct MoveFileTool: Tool {
594716
@available(iOS 26.0, macOS 26.0, visionOS 26.0, *)
595717
nonisolated struct ListDirectoryTool: Tool {
596718
let name = "list_directory"
597-
let description = "List contents of a directory"
719+
let description = "List contents of a local directory. Use ~/Developer for the user's Developer folder."
598720

599721
@Generable
600722
struct Arguments {
601-
@Guide(description: "The absolute path to the directory")
723+
@Guide(description: "Directory path, such as ~/Developer or ~/Downloads")
602724
let path: String
603725

604726
@Guide(description: "If true, list recursively (default false)")
@@ -658,4 +780,40 @@ nonisolated struct CheckPathTool: Tool {
658780
return String(data: data, encoding: .utf8) ?? ""
659781
}
660782
}
783+
784+
@available(iOS 26.0, macOS 26.0, visionOS 26.0, *)
785+
nonisolated struct BashTerminalTool: Tool {
786+
let name = "bash"
787+
let description = "Execute a short local terminal command and return stdout plus stderr. Use this for real local facts, repo checks, git status, ls, rg, find, build, and test commands."
788+
789+
@Generable
790+
struct Arguments {
791+
@Guide(description: "The shell command to execute, such as pwd, ls -la ~/Developer, git status --short, or rg -n pattern")
792+
let command: String
793+
794+
@Guide(description: "Working directory for the command. Use ~/Developer or a project path when relevant. Defaults to the user's home directory.")
795+
let workingDirectory: String?
796+
797+
@Guide(description: "Timeout in seconds. Defaults to 10 and is capped at 120.")
798+
let timeoutSeconds: Int?
799+
}
800+
801+
func call(arguments: Arguments) async throws -> String {
802+
let cwd = try FileToolsManager.shared.validatePath(arguments.workingDirectory ?? "~")
803+
var isDirectory: ObjCBool = false
804+
guard FileManager.default.fileExists(atPath: cwd.path, isDirectory: &isDirectory), isDirectory.boolValue else {
805+
throw FileToolsManager.FileToolError.isNotDirectory(cwd.path)
806+
}
807+
808+
let result = try await TerminalCommandRunner.run(
809+
command: arguments.command,
810+
workingDirectory: cwd,
811+
timeoutSeconds: arguments.timeoutSeconds ?? 10
812+
)
813+
let encoder = JSONEncoder()
814+
encoder.outputFormatting = .prettyPrinted
815+
let data = try encoder.encode(result)
816+
return String(data: data, encoding: .utf8) ?? ""
817+
}
818+
}
661819
#endif

0 commit comments

Comments
 (0)