All notable changes to this project will be documented in this file.
The format is based on https://keepachangelog.com/en/1.1.0/, and this project adheres to https://semver.org/.
tool_choicereaches the macOS 27 runtime (#510 item 3, #197). On macOS 27 apfel setsGenerationOptions.ToolCallingModefor the request:auto(and an omittedtool_choicewith tools in scope) maps to.allowed,noneto.disallowed, on both/v1/chat/completionsand/v1/responses; the debug trace names the applied mode (runtime_mode=). The SDK's.requiredmode is deliberately NOT used: with apfel's out-of-band tool calling (#119 - transcript tool definitions, no registeredFoundationModels.Tool) the runtime rejected every.requiredrequest withLanguageModelError"An unsupported generation guide was used" (0/10 seeds survived on macOS 27.0.1/M2), while apfel's existing prompt steering +ToolPolicyenforcement satisfiedtool_choice: "required"on 10/10 of the same requests - sorequiredand named functions keep the steering + enforcement path on every OS (there is also no per-tool mode in the SDK). Measured before/after on the bundled calculator'smultiplyschema, 10 seeds each:requiredon a greeting prompt 10/10 -> 10/10 tool calls,autoon the same prompt 0/10 -> 0/10,autoon a multiplication prompt 10/10 -> 10/10,noneon the multiplication prompt 0/10 -> 0/10 - behavior preserved, with the runtime now told the same contract. macOS 26 generation options are byte-identical to before. NewApfelCoresurface (additive):ToolCallingDirective(#510)- Typed
LanguageModelErrorclassification on macOS 27 (#510 item 4, #197). Every main-target catch site now classifies via the typed cases before falling back to the mirror/keyword classifier (which remains the macOS 26 path and the fallback, #181/#193 tests untouched). Context overflow names the real numbers on macOS 27 - CLI:[context overflow] Input exceeds the model's context window: the input is 4362 tokens, the window is 4096 tokens.(exit 4), server: the same message on the 400context_length_exceeded, from the SDK payload when the model throws and from apfel's own counted numbers when the pre-generation budget check fires; macOS 26 keeps the generic wording byte-identical (verified on macOS 26.1). A typedrateLimitedwith aresetDatebecomes aRetry-Afterheader on the 429.timeout,unsupportedCapabilityandunsupportedTranscriptContenthave no dedicatedApfelErrorhome and map to.unknownwith the SDK message preserved. NewApfelCoresurface (additive):ApfelError.contextWindowExceeded(tokenCount:contextSize:),ApfelError.rateLimitedUntil(retryAfterSeconds:),ApfelError.retryAfterSecondsproperty andApfelError.retryAfterSeconds(until:now:)(#510) Applies to binaries linked against the macOS 27 SDK (the GitHub release tarball and the Homebrew tap;make preflightnow asserts the stamp). A binary built from source with a plainswift buildor an older SDK is stamped 26.0 and keeps the generic messages.
GenerationOptionsare built with the macOS 27 SDK'sinit(samplingMode:...)instead of the deprecatedinit(sampling:...)(#510 item 5). The new initializer is back-deployed by the SDK to macOS 26 and forwards to the old one, so the sampling mapping (temperature 0 greedy, top-p nucleus, seeded top-k) is identical on both OS versions - verified via the seeded-determinism integration tests on macOS 27.0.1 and a seeded--temperature 0double-run on macOS 26.1. This removes the only deprecation warning in a macOS 27 SDK build (#510)
- A context overflow in the middle of a streamed answer on macOS 27 truncates gracefully again (
finish_reason: length, partial content kept, exit 0) instead of discarding the streamed text with exit 4 / HTTP 400: the typedcontextWindowExceededcase is now treated likecontextOverflowby the stream resolver. macOS 26 was never affected (#510).
- Image input on macOS 27 (#510 item 2).
/v1/chat/completionsand/v1/responsesaccept OpenAI-shape image parts (image_url/input_image) as base64 data URLs (image/png,image/jpeg,image/webp,image/heic,image/giffirst frame; 20 MB base64 cap per image; longest side downscaled to 4096 px;detailaccepted and ignored) and attach them to the on-device model natively - streaming,json_schema, tool-calling rounds and history turns included, with image turns surviving session rebuilds and retries. Remotehttp(s)URLs are always 400 ("apfel does not fetch remote images - send a data URL"; 100% on-device), andfile://URLs / local paths are always 400 (the server never reads files for an HTTP client). The request-body cap is 24 MiB where images are accepted; macOS 26 keeps the 1 MiB cap and its exact existing 400, extended with "image input requires macOS 27". CLI:-f photo.jpgand piped image bytes on macOS 27 attach the image natively IN ADDITION to the extracted OCR/classification text (--debugreports which), and--count-tokenssays on stderr that the attachment is priced by the runtime at generation time. NewApfelCoresurface (additive):ImageInput,ImageInputPolicy,ImageURLContent,ContentPart.image_url,OpenAIMessage.textIgnoringImages/imageParts,ResponsesInputItem.imageParts/unsupportedPartTypes, validatorimagePolicyoverloads,ApfelError.invalidImageInput,BodyLimits.visionMaxRequestBodyBytes(#510) - Model capabilities on the wire (#510 item 6, capabilities half).
apfel --model-infogains acapabilities:line,/healthand/v1/modelsgain acapabilitiesarray (snake_case names read fromLanguageModelCapabilitieson macOS 27:vision,tool_calling,guided_generation,reasoningwhen present) plus acapabilities_reportedboolean that isfalseon macOS 26 - mirroringcontext_window_measuredso clients can feature-detect instead of guessing. NewApfelCoresurface (additive):ModelCapability,CapabilitiesReport(#510)
usagecomes from the FoundationModels runtime itself on macOS 27 (#510 item 1, #504). Every response on macOS 27 carriesResponse.usage(and each stream snapshot carries the running equivalent), so/v1/chat/completions(non-stream, stream withstream_options.include_usage, MCP auto-execute,json_schema),/v1/responses(non-stream and stream) and the CLI's finish-reason check now takeprompt_tokens/completion_tokensstraight from the SDK instead of making separatetokenCount(for:)round trips before and after the model call; requests with several model calls (tool-call rounds, tool-policy repair rounds) report the per-call sum, and/v1/responsesnow fillsinput_tokens_details.cached_tokenswith the runtime's real prefix-cache number. Measured on an idle M2 (macOS 27.0.1), non-streaming "Reply with exactly: hello": median 0.49 s before, 0.34 s after (x5curl time_total) - most of the server-vs-fmgap from #504. The numbers themselves become the runtime's own accounting: the same hello request reportsprompt_tokens60 instead of 14 (Apple's chat-template framing is now included) and tool definitions are priced as the runtime actually renders them (~113 tokens for a definition the entry-counting path priced at ~1635). macOS 26 is byte-identical on the wire: no runtime-reported usage exists there, and the TokenCounter path runs unchanged (verified against the v1.13.0 binary on macOS 26.1). Refusals and pre-response errors still count via TokenCounter on both OS versions - a thrown error reports no usage. Context-budget pre-flights and--count-tokensare unaffected. NewApfelCoresurface:TokenUsage(additive).
-
Documentation no longer claims an 8192-token context window on macOS 27. That figure came from a macOS 27 beta; on the shipped macOS 27.0.1 the model reports 4096 tokens (
context_window_measured: true), the same as macOS 26. README and docs now say "4096 tokens on macOS 26 and on M1/M2 Macs, 8192 on macOS 27 with an M3 or newer chip and 12 GB+ (Apple's AFM 3 Core Advanced model)" and point toapfel --model-infofor the live value. New page docs/apfel-vs-fm.md compares apfel with Apple'sfmCLI that ships in macOS 27 (#192, #193). -
docs/file-extraction.mddocuments that macOS 27's default guardrails sometimes refuse prompts built from OCR'd image text (3 of 5 runs on the Apollo 11 plaque fixture, none on macOS 26) and that--permissiveis the intended remedy; the OCR integration test now runs with--permissivefor that reason (#193).
- MCP tool calls are validated against the tool's declared
inputSchemabefore execution, and schema-rejected calls are retried with a corrective re-prompt. On macOS 27 the model can invent an argument shape - e.g.multiply({"numbers": [247, 83]})against a schema requiringaandb- and an all-optional-params server then "succeeds" with defaults (returning 0). apfel now checks that everyrequiredkey is present and that no keys outside a non-emptypropertiesappear (additionalProperties: truelifts that check; an emptypropertiesobject does not close the schema, matching JSON Schema), rejects the call without executing it, feeds an error tool-result naming the tool, the missing/unexpected keys and the expected parameters (with types) back, and re-prompts with the tool schemas back in scope plus a reliability-tested corrective instruction, so the model re-emits the call with the correct names within the existing re-prompt cap. The model's habit of dropping the final}of the retried arguments is repaired when (and only when) the balanced text parses as JSON. Valid calls take exactly the same path as before; measured 10/10 correctmultiplyanswers against the bundled calculator and 5/5 against a defaults-answering server on macOS 27.0.1. NewApfelCoresurface:MCPProtocol.validateToolArguments(name:arguments:inputSchemaJSON:)andMCPProtocol.toolRetryPrompt(name:inputSchemaJSON:)(#193). The repair only adds missing closing braces/brackets after a complete value and never closes a truncated string or number, so a call like{"path": "/usr/lostill fails loud and is retried instead of executing with a fabricated value.additionalProperties: trueor a schema object for it keeps extra keys allowed. - Guardrail hits on macOS 27 are classified again for binaries built against a macOS 26 SDK (every Homebrew, nixpkgs and MacPorts build so far). macOS 27 throws the legacy
GenerationErrorto such binaries without a case name in its mirror (May contain unsafe content, descriptionDetected content likely to be unsafe), and the classifier returned.unknown: the CLI exited 1 with[error]instead of 3 with[guardrail], and the server answered HTTP 500server_errorinstead of 400content_policy_violation. The type-name branch now falls through to the description keywords. Binaries built against the macOS 27 SDK receiveLanguageModelError("The model's safety guardrails were triggered.") and were already classified correctly.--debugnow prints the raw error type, mirror and description next to the classification so the next OS change is diagnosable from the terminal (#193). apfel --codeon macOS 27. The new on-device model read the steering directive's phrase "use a correct language info string on the fence" as a data field and answered with{"language_info": {"name": "English", "iso_639": "en"}, "command": "brew outdated"},# lang: pythonsuffixes or aLANG=en_USprefix instead of a bare command. The directive now shows the fence by example ("three backticks followed by the language name, for example ```bash") and no longer says "info string"; verified 3/3 prompts on macOS 27.0.1 and 3/3 on macOS 26.1 with the same wording (#193).
- Structured-output and tool-parameter schemas resolve local
$refpointers.response_format.json_schema(Chat Completions),text.format(Responses),apfel --schemaandtools[].function.parametersall run through one compiler, which now expands#/$defs/...and#/definitions/...references in place - including references to any local node, RFC 6901 escapes (~0,~1) and percent-encoded segments, adescriptionsibling next to the$ref, definitions that reference other definitions, and the PydanticOptional[Model]shapeanyOf: [{"$ref": ...}, {"type": "null"}]. Ordinary Pydantic / zod models with nested types now work throughclient.chat.completions.parse(...)without a hand-flattened copy. External references (https://...,other.json) are rejected without any filesystem or network access, unresolved pointers and recursive schemas are rejected explicitly, and expansion is bounded (16 nested references, 512 nodes). NewApfelCoresurface:SchemaIR.boundedInteger,.boundedNumber,.boundedArray;SchemaParser.Error.externalReference,.unresolvedReference,.cyclicReference,.referenceDepthExceeded,.schemaTooLarge,.unsupportedConstraint,.invalidConstraint, andSchemaParser.Error: CustomStringConvertible- additive enum cases, a MINOR change under the STABILITY.md enum-evolution policy (#479). - Numeric and array bounds reach the model.
minimum/maximum/exclusiveMinimum/exclusiveMaximumonintegerandnumbernodes andminItems/maxItemson arrays were parsed and then discarded; they now compile to FoundationModels generation guides and array element limits, so aratingdeclared1..5is generated in1..5and atagsarray declaredminItems: 2, maxItems: 3has two or three entries. Exclusive bounds become the equivalent inclusive bound (the adjacent integer, or the adjacent representableDouble), and the draft-4 booleanexclusiveMinimum: trueform is honoured. Supported and rejected keywords are documented indocs/openai-api-compatibility.md, "JSON Schema support" (#479).
- Two differently shaped nested objects (or string enums) that share a property name no longer lose one shape. FoundationModels hoists every named node into
$defsby name and lets the first definition win, and apfel named nested nodes after their property key - so withperson.contact {email}andcompany.contact {phone}in one schema,company.contactwas generated with the email shape: HTTP 200,strict: trueechoed back, and output that violated the caller's schema with nothing in the response to show it. Object and enum names are now unique per distinct shape within one schema document; a later, differently shaped node with the same key is titledcontact_2. Identical shapes still share one name (#479). - Validation keywords that on-device schema-guided generation cannot enforce are rejected before generation instead of being silently dropped. The response is a 400
invalid_request_errornaming the keyword and its JSON pointer ("multipleOf" at #/properties/price ...) on/v1/chat/completions- also withstream: true, before any SSE frame - and/v1/responses;apfel --schemaexits 2 with the same message and the file name. Rejected:multipleOf;minLength,maxLength,pattern,contentEncoding,contentMediaType,contentSchema;uniqueItems: true,contains,minContains,maxContains,prefixItems,additionalItems;minProperties,maxProperties,patternProperties,propertyNames,dependentRequired,dependentSchemas,dependencies, schema-valuedadditionalProperties/unevaluatedProperties/unevaluatedItems;enum/conston non-string types;not,if,then,else,$dynamicRef,$recursiveRef. Unsatisfiable schemas are rejected the same way:minimumabovemaximum,minItemsabovemaxItems, negative or fractional counts, an emptyenum,requirednaming a property that is not declared. Keywords that do not apply to the node's type (minLengthon an integer), unknown keywords, booleanadditionalProperties, and annotations (title,default,examples,format,$schema,$id,nullable,x-*) stay accepted. Tool parameter schemas keep the documented text-injection fallback rather than failing the request. A stringenumwithout an explicittype(PydanticLiteral[...]) now parses as a string choice instead of an empty object, and a stringconstas a single-value choice (#479).
make installandmake buildno longer fail under Command Line Tools on SwiftPM 6.4+. SwiftPM 6.4 changed the default--build-systemfromnativetoswiftbuild, which dies with "Unknown error parsing property list" without Xcode. All Makefile andscripts/*.shswift buildandswift runinvocations now pin--build-system native, and a policy test keeps it that way.docs/install.mdupdated with a troubleshooting section for users runningswift builddirectly (#194).- Trimming client-supplied history no longer splits a tool exchange. Every strategy selected individual transcript entries (
suffix(k)/prefix(k)/ a per-entry window), so a boundary landing between an assistanttool_callsmessage and thetoolmessages answering it handed the model an orphaned call or an unexplained result, and a trailing tool result could be trimmed away entirely while the synthetic "respond to the tool result above" prompt still referenced it - underoldest-firstthe continuation lost the results even when every message was small. A pureApfelCore.ToolExchangeGroupingnow treats a tool-call message and all its matching results as one indivisible group;newest-first,oldest-first,sliding-windowandsummarizeselect whole groups (a tool exchange counts as onex_context_max_turns/--context-max-turnsturn), the exchange a trailing tool result belongs to is pinned and priced as required (too large for the runtime-derived budget is a context-overflow error before generation, not a silent drop), and the summarizer describes tool calls and results instead of dropping them. Tool results resolve their name throughtool_call_idinstead of the optional clientname, and a result that answers no call, a call with a missing or duplicate result, or atoolmessage withouttool_call_idis rejected up front with the offendingmessages[i]path - 400 on/v1/chat/completions, exit 2 forapfel --messages- matching the OpenAI API. The--mcpauto-execute follow-up now prices a large tool result against the transcript it will actually build (instructions, originating prompt, tool-call entry, final prompt) instead of the joined message text, so the #221 truncation leaves room for the pinned exchange (#482). tool_choiceandparallel_tool_callsare now enforced at the response boundary instead of only steered by prompt instructions. Before,tool_choice: "none"still let tool-call-shaped model output through as an executabletool_callsresponse (and, with--mcp, still auto-executed it), a forced choice the model ignored came back as HTTP 200 with a plain-text answer that looked complete, a call to a function the client never defined was exposed as-is, andparallel_tool_callswas not decoded at all. A pureApfelCore.ToolPolicynow resolves the contract fromtool_choice, the effective tool set (client tools or attached MCP tools) andparallel_tool_calls, and judges every detected call on the non-streaming, streaming, MCP auto-execute and/v1/responsespaths:nonemakes tool-shaped output ordinary content and disables MCP dispatch; arequiredor named choice that nothing in scope can satisfy is a 400 withparam: "tool_choice"before generation; a named choice presents only that function to the model and may name an attached MCP tool; plain text under a forced choice, or a call to a function outside the scope, is a typed 500 (code: "tool_choice_not_satisfied"/"tool_call_not_allowed", terminating an SSE stream with the same error object);parallel_tool_calls: falsereturns at most one call./v1/modelsadvertisesparallel_tool_calls(#480).
- Exported
APFEL_SYSTEM_PROMPT,APFEL_TEMPERATURE,APFEL_MAX_TOKENS,APFEL_CONTEXT_STRATEGY,APFEL_CONTEXT_MAX_TURNSorAPFEL_CONTEXT_OUTPUT_RESERVEno longer makeapfel --serve(and--benchmark,--model-info,--update) exit 2 with an error about a flag the user never passed. The #370 guard rejected env-derived prompt defaults exactly like explicit flags; those variables are standing defaults for prompt modes, so they are now dropped in input-ignoring modes with a one-line stderr noticeapfel: ignoring APFEL_... in --serve mode. Explicit flags keep the hard error (#496). max_completion_tokensin chat-completions requests was silently ignored becauseChatCompletionRequestonly decodedmax_tokens. Modern OpenAI clients (LangChain, openai-python) sendmax_completion_tokensinstead, so the caller's output-token cap was dropped and the model generated an unbounded response. Both fields are now decoded and resolved to one effective limit; providing both with different values returns 400 withparam: "max_completion_tokens"./v1/modelsadvertises the new field (#478).apfel --model-info,/healthand/v1/modelsno longer present the assumed context-window floor as a measured value.TokenCounter.contextSizefalls back to 4096 whenSystemLanguageModel.contextSizereports 0, which it does for the whole macOS 27 cold start (~80s) and whenever the model is not ready - the floor is correct and stays, because without itinputBudgetgoes negative and the model never warms up (#192). What was wrong is that the fallback was rendered indistinguishably from a real reading. On macOS 26 that is invisible, since the floor equals the true window; on macOS 27 the true window is 8192, so every one of these surfaces reported half the machine's real capacity as fact, and/healthis what an OpenAI-compatible client reads to sizemax_tokens. The measured/assumed distinction now lives inApfelCore.ContextWindowTracker,--model-infomarks an assumed value, and/healthand/v1/modelscarry an additivecontext_window_measuredboolean -context_windowstays a positive integer, so the #192 guarantee and existing clients are unaffected (#491).
- An MCP server can no longer hang apfel forever.
--mcp-timeoutbounded only the read side;senddid a blockingwrite(2)with no deadline at all. The amplifier was the ping path:classifyIncomingechoes the server'sidback verbatim, so the server chose how many bytes apfel had to write - a ping carrying a 256 KiB id forces a ~256 KiB reply into a ~64 KiB pipe, and if the server then stops reading its stdin apfel blocks inwrite(2)and never returns. The whole process wedged and had to be SIGKILLed,Ctrl-Cnever ran, and in--servemode one misbehaving MCP server took the model endpoint down for every client. Writes are now non-blocking and bounded bypollagainst the same deadline, and an oversized ping id is refused rather than echoed (#418). chatFailureandresponsesFailurewere identical duplicates of the same HTTP failure builder; they are now one sharedopenAIFailure. Internal only, no change to wire behaviour (#437).- The
--chathistory file is created0600before libedit writes prompts into it, and its parent directories0700. libedit'swrite_history()usesfopen(path, "w"), which creates the file at0666 & ~umask- 0644 under the default umask - so the user's prompts were written into a world-readable file and only chmod'd afterwards. Ifwrite_historyfailed, the early return left it at 0644 for good. An existing file that has drifted to a wider mode is now corrected too. History persistence is still opt-in viaAPFEL_HISTFILE(#473). - A stream retry no longer splices the previous attempt's partial output onto the new attempt's text.
StreamPrintSinktracked only a character-count high-water mark and assumed a retried generation reproduces the same prefix, so when the retry produced different wording - the normal case at any temperature above 0 - the user saw the first attempt's prefix welded to the tail of the second, with exit code 0 and text that looked plausible. The sink now compares the actual emitted text and signals the discontinuity instead of hiding it (#402). - Exhausting the MCP re-prompt cap is now reported as an error instead of a successful answer. When the model was still requesting a tool call after 3 rounds, apfel stripped the pending tool-call JSON and returned whatever text was left with
finish_reason: stop, so the caller received a half-finished answer with no indication that tool work had been abandoned. Stripping the raw JSON is right - it must never leak to the user (#187, #358) - but presenting the remainder as a normal completion was not. Both the CLI and HTTP paths now fail loudly, and the cap lives in one shared constant rather than being written twice (#435). shellOutputdrains the child's stdout before waiting for it to exit, and distinguishes failure from empty output. Reading afterwaitUntilExit()deadlocks once the child's output exceeds the OS pipe buffer - the child blocks inwrite(2)waiting for the parent to read, and the parent will not read until the child exits. The helper also swallowed launch errors, discarded stderr and ignored a non-zero exit status, so all three failure modes were indistinguishable from a successful empty result; it now returnsnilon failure. Latent rather than live before this -brew info --json=v2 apfelis ~4.5 KB against a 64 KiB pipe - but one formula-metadata growth spurt from hangingapfel --update(#433).- A remote (Streamable HTTP) MCP server that rejects
notifications/initializednow fails the handshake instead of being attached and used anyway. The remote path discarded the error withtry?while the stdio path propagated it, so the two transports disagreed about whether initialization had to succeed (#432). - Blocking MCP stdio I/O now runs on a GCD thread instead of
Task.detached. The code said it ran "off the cooperative thread pool", butTask.detachedchanges context inheritance (priority, task-locals, cancellation), not the executor - a detached task is still scheduled on Swift's cooperative executor, so the synchronouspoll/readloop parked a cooperative worker for the whole exchange while holding a lock and blocking on the subprocess (#431). - A truncated
/v1/responsesstream now ends withresponse.incompleteinstead ofresponse.completedcarrying a payload that says"status": "incomplete". Both terminal paths computed the correct status and then hardcoded the wrong event name, andresponse.incompleteappeared nowhere in the codebase - so a client switching on the event name, which is what SSE clients do, recorded a truncated answer as a complete one (#412). /v1/responsesreturns 400 for malformed input content instead of 500, and rejects image parts instead of silently discarding them. An empty string, a non-string scalar, or a text part with notextall reached generation and surfaced as HTTP 500 "Last message has no text content" - caller mistakes reported as server faults - while aninput_imagepart was dropped and the request succeeded with 200, so the caller believed the image had been considered. Chat Completions already got both right (.emptyLastMessageContentfrom #233, and.imageContent); the Responses surface now matches (#409).- An unrecognised
roleon any message other than the last is now a 400 instead of silently deleting that turn.ChatRequestValidatoronly checkedmessages.last?.role; earlier messages went toContextManager.historyEntry, which returnsnilfor an unknown role, and the caller'scompactMapremoved them. A typo like"assistent"- or the wrong case,"User"- deleted conversation history and the answer was generated against a conversation the caller never sent, with HTTP 200 and no indication anywhere in the response. OpenAI returns a 400 here, so SDKs and test suites written against the real API would not catch it either.developeris now a recognised instruction role and reaches the model, rather than being accepted and then dropped by the same mechanism (#405). apfel --count-tokens --mcp <server>now includes the prompt in the reportedtotal.ContextManager.makeSessiondeliberately excludes the final prompt from its transcript entries because generation sends it separately, so every other call site wraps the result insessionInputEntries(builtEntries:finalPrompt:options:)to add it back - the--mcpbranch assigned the raw entries instead. The total was therefore the same no matter how long the prompt was, making both thefitsverdict and the reported headroom wrong exactly when a user is checking whether a long prompt will fit (#399).- Repeating
--messagesnow follows last-flag-wins like every other repeated flag in the parser.--messages -setmessagesFromStdinand a later--messages <file>setmessagesJSONwithout clearing it, andmain.swiftchecks the stdin flag first - so the file that was read and validated was thrown away, apfel read empty stdin, and it exited 2 withinvalid --messages JSON from stdinwhile a perfectly valid conversation file sat unused. The error pointed at stdin, which the user had not asked for on that invocation (#416). - Unreadable or invalid-UTF-8 piped stdin now fails loudly instead of being silently treated as empty.
readStdinDatamapped any read failure to emptyData()andstdinPromptTextmapped a failed UTF-8 decode to"", soproducer | apfel 'summarise this'returned a confident answer about nothing at all - with exit status 0, so no caller could detect it - and the "piped input was empty" hint actively misled, because the input was not empty. Data loss under a success exit code is the one failure mode a UNIX tool must never have (#397). - A JSON number outside
Double's range in a caller-supplied schema is now rejected with a 400 instead of being silently rewritten tonull.AnyCodableended with an unconditionalvalue = nilfallback, so{"maximum": 1e999}became{"maximum": null}- the schema apfel applied was not the schema that was sent, the response could violate a constraint the caller believed was in force, and nothing in the request or response indicated the substitution.RawJSONbacksresponse_format.json_schema.schemaandtools[].function.parameters, so this reached both. An explicitnullis still a legitimate value (#455). - Benchmark cases no longer discard errors with
try?. Three timed operations swallowed every failure, so an operation that threw on every iteration was still timed and still reported - as a fast,validated: trueresult. A benchmark that measures nothing looked like a benchmark that got faster, which would have hidden exactly the regression the case exists to catch. The root cause wasmeasure(iterations:)accepting a non-throwing closure, forcing every call site to discard errors at the boundary; it now takes a throwing closure and rethrows (#436). - The three #213 permit-leak regression tests now run in CI.
test_stream_permit_release.pycarried a whole-suitepytest.mark.model, so the per-PR job's-m "not model"filter excluded them, and the job did not name the file anyway - the guard against a remote unauthenticated permit-exhaustion DoS was running nowhere except a full local release. None of the three needs the model: every request fails validation and returns 400 before aLanguageModelSessionis constructed. They are now markedserial(they assert/health's globalactive_requestscounter, so they cannot share servers with parallel workers) and run as their own step (#434). /v1/responseshonours thetruncationfield instead of accepting it, silently truncating anyway, and echoing"disabled"back regardless.ResponsesRequestnever decoded the field, the handler hardcoded the newest-first strategy, and the encoder hardcoded the string"disabled"into every response - so a caller who explicitly opted out of truncation got silently truncated data and a 200 claiming truncation was off, with no way to detect the loss."disabled"now maps to the strict context strategy and returns 400 on oversized input (matching Chat Completions'x_context_strategy: strict, which the two surfaces previously disagreed on),"auto"and absent keep newest-first trimming, unknown values are a 400 namingtruncationinparam, and the envelope echoes what was actually applied (#391).response_format: json_schemaagainst a server started with--mcpnow returns a 400 naming the conflict, instead of being silently ignored while the model answered in free text.handleChatCompletionbuilt theGenerationSchemaup front, but the MCP auto-execute branch returned before the schema-guided generation branch was ever reached. Structured output is documented as a guarantee (#167), so any client that parses the result broke the moment an operator added--mcpto the server - with no error, no warning, and nothing visible in the request/response pair (#392).- Every
systemmessage after the first is no longer silently discarded.ContextManager.makeSessionstrips all system messages out of the conversation andbuildInstructionscopied only the first one back, so a caller stacking a base system prompt and a task-specific one - which SDKs and agent frameworks routinely do - got a 200 and an answer that ignored instructions they believed were in force, with no warning and no token accounting. All system messages are now joined into the instructions block in order. Also affects Responsesdevelopermessages and CLI--messagescombined with-s(#390). - The server sets a 30-second HTTP idle timeout, so a stalled request body can no longer hold a concurrency permit forever. The route handler takes the permit before the body is read and the Hummingbird
Applicationwas built with no server channel configuration, leavingHTTP1Channel.Configuration.idleTimeoutatnil- no idle, read, or connection timeout anywhere in the stack.--max-concurrentconnections carrying ~150 bytes each took the server out of service for as long as they were held, and this needed no attacker: a client that died or lost its network mid-upload leaked a permit until the OS reaped the half-open connection. Long generations and streaming are unaffected (#463). - MCP tool arguments and full tool results are no longer written to the server's stderr without
--debug.RequestLog.request_bodyandresponse_bodyare deliberately debug-gated and truncated, but theeventsarray carried the same class of data with neither protection, so anything an MCP tool returned - file contents, database rows, API responses, credentials the tool was given - landed in the log on a defaultapfel --serve --mcp .... Underbrew servicesthat stderr is a durable, world-readable file (var/log/apfel.log, mode 0644). Without--debugthe event now records only the tool name, ok/error status and result length; with--debugarguments and results appear, truncated to 512 characters (#464). --allowed-origins '*'now fires the same red startup warning as--no-origin-check, and the banner readsdisabled (all origins allowed)instead of the actively misleadinglocalhost only (..., *). The wildcard short-circuitsOriginValidator.isAllowedtotruefor every origin and forcesAccess-Control-Allow-Origin: *, reaching the identical posture to--no-origin-checkby a route the #232 warning did not cover. Both the banner and the warning now key off a singleServerSecurity.originValidationConstrainspredicate (#465).- A deeply nested JSON value in a caller-supplied schema no longer aborts the server process.
AnyCodablerecursed once per nesting level with no cap; Foundation's own JSON scanner only rejects at ~512 levels, far deeper than the stack of the cooperative-pool thread request bodies are decoded on, so a ~1.3 KB unauthenticated POST totools[].function.parameters,response_format.json_schema.schemaortext.format.schemaexhausted the stack and killed the process with SIGBUS, taking every in-flight request with it. Because the crash happened during body decoding, before the handler ran,--tokendid not protect against it. Nesting is now capped at 64 levels and an over-nested body is rejected with a 400 (#462). - Streaming to a closed stdout pipe (
apfel --stream ... | head -1) no longer aborts the process with an uncatchableNSFileHandleOperationExceptionand a stack trace on the user's terminal. apfel ignores SIGPIPE process-wide (#215), so a vanished reader surfaces as EPIPE, and the legacy non-throwingFileHandle.write(_:)converts that into an Objective-C exception Swift cannot catch. Writes now go throughApfelCore.writeTolerantly: stdout exits quietly with status 141 (128 + SIGPIPE) the way a UNIX filter does, while a lost diagnostic on stderr is dropped without changing the exit status. The same latent abort inprintStderr,printErrorand the retry banner is fixed in the same change (#389). - Scalar tool-call
arguments(a number or a bool, e.g."arguments": 7) no longer abort the process.JSONSerialization.data(withJSONObject:)raises an Objective-CNSInvalidArgumentExceptionon a non-container top-level value, whichtry?cannot catch, so any client able to steer the model into emitting that shape could kill the server and every in-flight request with it. The write now uses.fragmentsAllowed, excludesNSNull, and routes the result throughensureJSONArguments, so a bare scalar is wrapped exactly as a bare string already was. Object and string arguments are unchanged (#388). - CI installs Python test dependencies into a venv instead of
pip3 install --break-system-packagesinto the runner's brew-managed system prefix. brew'styping_extensionsships noRECORDfile, so pip could not uninstall it to satisfy a transitive dependency and aborted the job - non-deterministically, on roughly a third of runs, regardless of the diff under test. Every red check in the PR queue had become meaningless as a result. Guarded byTests/integration/test_ci_python_env.py(#477).
- Bumped
github.com/hummingbird-project/hummingbirdfrom 2.25.1 to 2.26.0 (#382, Dependabot).
ApfelCore.EventStreamResponseHeaders: new public policy value describing the SSE response headers (Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive) as ordered (name, value) pairs - the single source of truth for every streaming endpoint, locked down by a unit test.
- Single-sourced HTTP response construction across the OpenAI-compatible handlers (internal, behavior-preserving): the five hand-rolled SSE header blocks in
Handlers.swift/ResponsesHandlers.swiftnow build fromEventStreamResponseHeadersvia a sharedeventStreamHeaders()helper, and the five hand-rolled JSON success responses plusopenAIErrornow route through the existingjsonResponse(_:status:)helper. No change to payloads, status codes, header names/values, or CORS.
/healthand/v1/modelsno longer reportcontext_window: 0on macOS 27 cold start (#192). On macOS 27 the SDK'smodel.contextSizereturns 0 during initialization (observed for 80+ seconds); the server previously cached this at startup and locked in 0 for the process lifetime.TokenCounter.contextSizenow uses a high-water mark that never regresses to 0 once a positive value is observed, with a floor of 4096 (the known minimum for any Apple Intelligence model). Both/healthand/v1/modelsread per-request instead of using a startup cache. This also fixes the generation deadlock whereinputBudgetreturned -512, rejecting all requests before the model could warm up.
- Documentation now describes the on-device context window as dynamic rather than a fixed 4096 (#192). The FoundationModels on-device window doubled from 4096 tokens on macOS 26 to 8192 on macOS 27 (confirmed on real hardware); apfel already reads the live size at runtime via
SystemLanguageModel.contextSize, soapfel --model-infoprints the true value on any OS. Swept the hardcoded "4096" references acrossREADME.md,docs/(incl.docs/coreai-impact.md, now recording the confirmed 8192 finding), thedemo/README.mdembed, and the CLAUDE.md honesty principle; measured macOS-26 walkthroughs and dated design artifacts keep their literals with a clarifying note. - Integration tests run in two marker-partitioned phases (#374): the model-free, parallel-safe partition first (
-m "not model and not serial", parallelized with pytest-xdist one-worker-per-file when installed), then the serial on-device-model phase (-m "model or serial"). Every test still runs exactly once andAPFEL_REQUIRE_FULL=1still forbids skips; cheap doc-drift gates now fail in seconds instead of after ~10 minutes of model tests.make preflightis light by default (build + unit + model-free phase, ~1.5 min warm;FULL=1restores the full qualification) becausemake releaseruns the complete suite against the stamped release binary anyway - one full model pass per release instead of two. Marker hygiene enforced by the newtest_marker_discipline.pysource-scan suite (runs on CI): whole-suitepytestmarkdeclarations for the generation-driven files (incl. the previously unmarkedtest_chat.py), a single sharedrequire_model()inconftest.py, and a newserialmarker for suites that mutate machine-global state (test_brew_service.py). Benchmark medians default to 3 runs (APFEL_BENCH_RUNS=5to restore the #264-era count).
--code: print only the code. Crops the response to the content of its first fenced code block (fence markers and surrounding prose removed); when the model returns no fence at all, the whole response passes through as the code, and a response that is exactly one inline code span is unwrapped. A steering directive is appended to the system prompt (composes with-s); extraction is deterministic regardless of model compliance.apfel --code "python calculator" > calc.pyandapfel --code "battery one-liner" | pbcopynow work without a hand-rolled fence stripper. Composes with-f,--messages, piped stdin, and-o json(envelope gains an advisorylanguagekey); rejects--stream,--chat, and--schemawith a usage error. New exit code 7: the model returned an empty response. Empirically validated against a 20-prompt battery across bash, git, awk, sed, curl, ffmpeg, python, swift, node, sql, jq, and German-language asks (#373).
-f file(and a positional prompt or piped content) is now honored in--chatinstead of being silently dropped. The extracted content is folded into the session instructions so the model already has it in context on the first turn, and chat prints a one-linecontext:notice on startup. Previouslyapfel -f code.swift --chatread/OCR'd the file at parse time and then threw it away (#370).
- Modes that neither read a one-shot prompt nor run per-request generation -
--serve,--benchmark,--model-info,--update- now reject a positional prompt,-f/--filecontent,-s/--system, or any generation/context tuning flag (--temperature,--top-p,--max-tokens,--seed,--context-strategy,--context-max-turns,--context-output-reserve) with a usage error (exit 2) instead of parsing and silently ignoring it.--context-statusis likewise rejected outside--chat.--servestill consumes--permissive,--retry*,--mcp, and the server flags. Found by an audit of the same silent-drop class as #370.
- A model-emitted tool call whose arguments are a quoted string with unescaped inner quotes (e.g.
"arguments": "{"value1": 1234, "value2": 5678}") is no longer dropped when the argument object is actually recoverable.salvageUnparseableToolCallnow runs a string-aware balanced-brace scan (extractFirstBalancedObject) over the raw arguments text and, when it finds a single balanced{ ... }that parses as valid JSON, substitutes it so the tool call executes; when no unambiguous object is extractable the raw text is kept so validation still fails loud, preserving #241's "never guess" principle. Follow-up to #358 (#367).
- CI now blocks any pull request that changes production source (
Sources/**, excluding the generatedBuildInfo.swift) without aCHANGELOG.md [Unreleased]entry, via thechangelog-gatejob andscripts/check-changelog.sh. This moves the empty-[Unreleased]enforcement from release time (stamp-changelog.sh, #263) to merge time, so a code fix can no longer merge changelog-less and stall the next release - the root cause of the v1.8.1 delay (#369).
- The CLI now prewarms the on-device model concurrently with input I/O (
LanguageModelSession.prewarm, the mechanism the server has used since #169): single/stream invocations overlap the model cold-start with the piped-stdin read, and--chatwarms while the user types the first message. No flags, no output changes - pure latency.--serve(own prewarm),--count-tokens(never calls the model), and--benchmark(would skew cold-start measurements) are excluded (#364).
POST /v1/responses: the OpenAI Responses API, served as a translation layer over the same on-device pipeline as chat completions. Supported: string and message-listinput(incl.input_textparts and thedeveloperrole),instructions,temperature/top_p/max_output_tokens/metadataecho,text.formatjson_objectandjson_schema(guided generation), non-streaming function tools returned asfunction_calloutput items, and streaming with the canonical named-event sequence (response.createdthroughresponse.completed,sequence_numberon every event) - verified end-to-end against the officialopenaiPython SDK'sclient.responses.create(), streaming and non-streaming. Honest 501s, never silent downgrades, for what a stateless on-device server cannot do:previous_response_id,store: true,background,reasoning,include, hosted tools,function_call_outputitems, and tools/json_schema combined withstream: true. Every response reports"store": false(#365).--messages <file|->: one-shot multi-turn on the UNIX tool surface. Pass an OpenAI-style conversation (a bare JSON message array or an object with amessageskey, from a file or piped stdin via-) and apfel prints the next assistant message - multi-turn agents in pure shell withjq, no server process, no TUI. Reuses the server's transcript building (ContextManager.makeSession) so CLI and server multi-turn semantics cannot drift, including the last-message-must-be-user-or-tool rule and context-strategy trimming. Composes with--streamand--schema; positional prompts,-f, and--chatcombinations are usage errors (exit 2) (#363).--schema <file>: guaranteed structured output on the UNIX tool surface. The single-shot CLI now accepts a JSON Schema file and constrains generation with FoundationModels guided generation (the sameDynamicGenerationSchemapath the server'sresponse_format: json_schemauses since #167), so stdout is always one schema-valid JSON object - no fence stripping, no invalid-JSON retries, jq-ready. Malformed or unsupported schemas fail at argument-parse time with exit 2;--chat,--stream,--count-tokens, and MCP combinations are rejected as usage errors;-o jsonwraps the object as a string in the standard envelope (#361).
- A model-emitted tool call whose JSON is unparseable (e.g. a literal
<escaped_json_string>placeholder with unescaped nested quotes, live-reproduced on the macOS 26.5.2 model at seed 7) no longer leaks the raw{"tool_calls": ...}protocol text to the client asmessage.content.detectToolCallnow salvages the function name from such an attempt and keeps the raw (still invalid) arguments, so the existing invalid-arguments recovery (#241) feeds a tool error back to the model instead.stripToolCallJSONmoved into ApfelCore (ToolCallHandler) with unit coverage, including the strip-to-end fallback for never-balancing garbage (#358). - The bundled MCP calculator no longer string-concatenates non-numeric arguments:
add({"a":"999","b":"1"})returned9991as a successful tool result (published verbatim in docs/EXAMPLES.md) andadd({"a":"abc","b":"1"})returned"abc1". Operands are now coerced to numbers and non-numeric input returns anisErrortool result. Covered by a new model-free direct JSON-RPC suite wired into CI (#322). --benchmark'stoken_counter_availablefield now reports tokenizer availability instead of model generation availability. On macOS 26.0-26.3 with Apple Intelligence enabled it saidtruewhile every count in the report was chars/4 - the pre-#315 misclassification surviving on this surface. This also givesisTokenCountingAvailableits one real caller again (dead code since #315 rewired the CLI) (#325, #328).--count-tokens --mcpon macOS older than 26.4 no longer counts MCP tool schemas as 0. The skip-session gate keyed on "any fallback" but only a genuinely unavailable model makes session construction unsafe; underosTooOldentries are now built normally and counted via the chars/4 fallback, which prices tool definitions - so--strictcan no longer false-pass while the real request overflows (#326).--count-tokenscan no longer report"approximate": falsefor numbers that were actually chars/4. IftokenCount(for:)throws at runtime, or availability flips after the pre-flight check, the counter records the fallback and the report folds it in, with a distinct stderr warning naming the runtime failure (#327)./health'ssupported_languagesno longer contains duplicates. The SDK reports locale variants (en_US,en_GB,en_AU...) whose language codes all collapse to the same bare code; the list is now deduplicated preserving SDK order (was:enx3,esx3,zhx3,frx2,ptx2 in 23 entries) (#329).- The
contextOverflowclient-facing message no longer hardcodes "4096-token": the window size is dynamic everywhere else (TokenCounter.contextSize) and the pinned number would become a lie the day the OS reports a different window (#330). - docs/EXAMPLES.md's Table of Contents now includes section 14 (File Extraction): the generator script's hardcoded TOC had drifted from the sections it emits, so every regeneration reproduced a TOC contradicting the body. A model-free CI test now asserts TOC-vs-body consistency (#331).
- The release workflow re-stamps the docs/EXAMPLES.md header with the version being released. The doc is regenerated from the installed binary before the bump, so its stamp was permanently one version behind the tag shipping it (#332).
- Documentation truthfulness sweep: CLAUDE.md's stale "53 prompts" claim dropped (the suite runs 60), the README keeps exactly one Swift-library link per the structure rule, the man page and docs/cli-reference.md now document the
--count-tokenschars/4 fallback and the JSONapproximatefield, stale "as tested" labels across docs/tool-calling-guide.md, docs/PERMISSIVE.md, and all ten docs/guides footers were refreshed with honest provenance, and STABILITY.md now defines the patch-release policy for fix-supporting public API (the v1.7.1TokenCountFallbackprecedent) (#333, #334, #335, #336, #338). - Test hardening against the macOS 26.5.2 model's aggressive in-band guardrail refusals: a shared conftest helper (broadened refusal matcher + seed rotation) now protects every seed-pinned content assertion across the MCP and OpenAI-client suites - one fixture was already receiving refusal text at its pinned seed and passing by luck. The two MCP-timeout tests now follow the
require_model()marker discipline, and the multibyte-backspace guarantee is proven end-to-end against the persisted line-editor buffer viaAPFEL_HISTFILE(#323, #324, #337, #339).
- The
--count-tokenschars/4 fallback warning now names the actual reason instead of always blaming Apple Intelligence. On a Mac running macOS older than 26.4 thetokenCount(for:)API does not exist at runtime even though generation works fine, so the old message ("Apple Intelligence unavailable") was false and misleading - it made real doc output look like a broken machine. The reason decision is a pure, unit-testedTokenCountFallbackin ApfelCore:osTooOld(names the required 26.4 and the actual OS version) wins overmodelUnavailable. Covered by unit tests and a model-free cli_e2e test that asserts the warning against the host OS version (#315).
- On-device file extraction for
-f/--file(and piped files) via the public lesbar package:apfel -f report.pdf "summarize"now reads PDFs (PDFKit text, Vision OCR fallback for scans) and images (Vision OCR text plus a short scene description) entirely on device, no network. Many image formats are supported (PNG, JPEG, HEIC, TIFF, GIF, BMP, WebP and more) alongside plain text and PDF. Piped files (cat report.pdf | apfel "summarize") route through the same extractor with format sniffing. Documented in docs/file-extraction.md (#211). - Shell completions via a new
apfel completions <shell>subcommand (bash,zsh,fish) that prints a completion script to stdout, plus the three generated scripts committed undercompletions/for packagers. The completion flag table is derived from the parser's ownCLIArguments.knownFlags(single source of truth, unit-enforced to match exactly), so a new flag cannot ship without its completion. Completions cover every flag, the five--context-strategyvalues,plain/jsonfor-o, file completion for-f/--file/--mcp/--system-file, and thecompletionssubcommand's own shell names. The Homebrew tap formula installs them to the standard per-shell directories, and the release tarball now shipscompletions/. Covered by generator unit tests (name-set equality, per-shell markers, strategy/file coverage) and cli_e2e tests including a committed-file-vs-binary drift guard (#259). - Opt-in persistent
--chathistory via theAPFEL_HISTFILEenvironment variable. Off by default (the honest, secure default): when unset, chat line-editing history stays in memory only and nothing is written to disk. When set to a path, prior history is loaded at startup (up-arrow reaches earlier sessions) and the file is rewritten on exit, bounded to the most recent 500 entries and chmod 0600 (it contains your prompts). A leading~is expanded. macOS libedit exposesread_history/write_history/history_truncate_filebut not GNU readline'sappend_history, so the merged in-memory list is written and then truncated to stay bounded. The opt-in path decision is a pure, unit-testedChatHistory.filePath(env:)in ApfelCLI; persistence is verified end-to-end via a PTY test (#259).
- Release tarballs now ship a Developer ID signed, notarized
apfelbinary and a checksum asset. The binary is signed with the "Developer ID Application: Franz Enzenhofer (7D2YX5DQ6M)" identity under a hardened runtime and the submission is notarized by Apple, so downloads outside Homebrew are no longer Gatekeeper-quarantined. It is intentionally not stapled - a bare CLI binary in a tarball cannot carry a stapled ticket, so Gatekeeper verifies notarization online. Each release also publishesapfel-<version>-arm64-macos.tar.gz.sha256as a second asset;scripts/post-release-verify.shcross-checks the tarball digest against it and the Homebrew tap formula and confirms the TeamIdentifier.make releasehard-fails if signing or notarization fails; localmake build/make package-release-asseton a machine without the identity still produce an ad-hoc binary (#226). - JSON output now ends with a single trailing newline.
apfel -o json ...,apfel --count-tokens -o json ..., andapfel --benchmark -o jsonpreviously printed the final JSON object with no terminating newline (last byte}), which madewhile readloops andwc -lawkward. This reverses the earlier GH-9 no-trailing-newline behavior; JSONL chat lines already ended with a newline and are unchanged (no double newline). Covered by a model-free cli_e2e test on--count-tokens -o jsonand an updated single-prompt JSON test (#259). - STABILITY.md now documents enum evolution for the ApfelCore library: public enums are non-frozen and may gain new cases in minor releases (always switch with a
defaultbranch); removals and signature changes remain major. The CI API-breakage gate enforces exactly this split instead of failing on every added case. - The tool-schema conversion cache (
SchemaConversionCache) is now a bounded LRU: when full it evicts only the single least-recently-used entry instead of wiping all 64. Previously the 65th distinct tool set flushed the entire cache, so two alternating clients each with more than 32 distinct tool sets caused pathological full-cache churn. Backed by a new pureLRUCachein ApfelCore with unit coverage of eviction and the hot-entry-survives property (#247).
- A chat-completions request with a negative
seedno longer crashes the entire server process.seed: -1trapped theUInt64conversion in the request handler - a remote, unauthenticated denial of service on the default loopback bind (one malformed curl killedapfel --serve). The validator now rejects negative seeds with HTTP 400 before the conversion is reached (#212). - Documentation test-count and release-step claims are back in sync with reality.
CLAUDE.md(Current Status, Build & Test, the CI section, and the release-step list) anddocs/release.mdhad drifted: they cited 687 unit / 301 integration (now 890 unit / 393 integration), an arithmetically impossible CI subtotal, and release steps that omitted the CHANGELOG stamp and the whole nixpkgs bump step. Counts were recomputed fromswift run apfel-testsand a fullmake test, the CI numbers were derived fromci.ymlplus-m "not model"collect counts (890 unit + 127 model-free integration), and the release-step lists now describepublish-release.shfaithfully (CHANGELOG stamp, signing, notarization, sha256 sidecar, nixpkgs bump) (#270). - The
--helpENVIRONMENT section now listsAPFEL_MCP_TOKEN(the bearer token for remote MCP servers) and describesAPFEL_MCPas comma-separated with colon accepted for local paths, matching the parser (commas are canonical because a colon would split remote URLs likehttps://host:8080) and the man page. Previously the token was only mentioned in the--mcp-tokenflag description and--helpcalledAPFEL_MCPcolon-separated, contradicting both the parser and the man page. A new section-scoped man-page test asserts the ENVIRONMENT lists in--helpand the man page contain the same variables (#257). - The relative link to the CLI reference in
docs/tool-calling-guide.mdno longer 404s on GitHub. The target was written asdocs/cli-reference.md, but since the file already lives indocs/, it resolved todocs/docs/cli-reference.md; it now points atcli-reference.md. A scripted sweep of the wholedocs/tree confirmed this was the only self-referencingdocs/link (#267). apfel --updateno longer hardcodes/opt/homebrew/bin/{brew,apfel}, which broke on non-default Homebrew prefixes (Intel/usr/local, a custom~/homebrew, etc.):detectInstallMethodcorrectly returned.homebrew, but the update shelled out to a nonexistent/opt/homebrew/bin/brew("Could not check for updates.") and the post-upgrade version echo ran the wrong/absent binary (printing "Updated to " with an empty version). The brew prefix is now derived from the resolved binary path (the component before/Cellar/or/opt/apfel/) via a purehomebrewPrefix(fromBinaryPath:)in ApfelCLI with unit coverage, with aPATHlookup ofbrew/apfelas fallback; the post-upgrade echo uses the same derived prefix (#260).- Chat line editing of non-ASCII input is now character-wise instead of byte-wise. apfel never called
setlocale, so it ran in the"C"locale regardless ofLANG=...UTF-8, and libedit's multibyte handling (mbrtowc/ct_encode) keys offLC_CTYPE- editing a line withü/é/emoji (backspace, arrow-left, Ctrl-W) operated on single bytes, so one backspace overéleft a dangling0xC3byte and misdrew the line.setlocale(LC_CTYPE, "")now runs once at startup in main.swift before anyChatLineEditoris constructed. Verified via a PTY: one backspace erases the whole 2-byte character (#256). - Ctrl-C at the interactive chat prompt now restores the terminal before exiting. The SIGINT handler
_exit(130)s straight out of libedit, so itsrl_deprep_terminal/atexit cleanup never runs and the tty was left in raw/no-echo mode - fine under zsh (which repairs its own tty) but broken forsh, script wrappers, and expect-style harnesses. The handler now captures the cooked termios before libedit switches to raw mode and restores it viatcsetattr(async-signal-safe) inside the handler. Exit code 130 (interrupted) is now documented in the man page EXIT STATUS and--helpEXIT CODES. Verified via a PTY: ICANON/ECHO restored and exit 130 (#251). - A context-rotation failure during a
--chatsession now exits nonzero instead of 0. Previously, iftruncateTranscriptthrew (e.g.--context-strategy strictwith history over budget throwingcontextOverflow), the loop broke, "Goodbye." printed, and the process exited 0 - so a wrapper script saw success despite the session dying, while single-prompt mode mapped the same error to exit 4. The rotation failure is now captured and rethrown after the loop so main.swift's top-level handler maps it viaexitCode(for:)(exit 4 for context overflow). A plainthrowinside the turn body would have been swallowed by the per-turn model-error catch, so the error is threaded out explicitly (#252). - Usage text on usage-error exits (exit 2) now goes to stderr instead of stdout, so a failed invocation no longer pollutes a downstream pipe with ~5 KB of help text.
printUsage(to:)takes a destination:--help/.helpkeeps usage on stdout with exit 0, while the no-args-at-a-terminal error path writes to stderr. Color for the stderr variant is gated on stderr's TTY-ness (#250). - ANSI color for stderr-destined output now keys off stderr's own TTY-ness instead of stdout's. Previously
styled()gated colorization onisatty(STDOUT_FILENO)for every write, soapfel --bogus-flag 2>err.login a terminal wrote raw escape codes into the log (and, conversely,apfel ... | catwith stderr on a TTY printed uncolored errors). A newstyledErr()keys offisatty(STDERR_FILENO), and all unconditional-stderr sites (error messages, debug logs, the tool log, the--servestartup banner, MCP/parse warnings) route through it. The gating decision is a pureColorPolicy.shouldColorizein ApfelCLI with unit coverage (#249). - An empty
NO_COLOR=(empty string) no longer disables color. Per https://no-color.org and apfel's own man page, only a non-emptyNO_COLORvalue disables ANSI color; an empty value (e.g. fromenv -i NO_COLOR=or a CI template) is now treated as absence. The env check moved to a pureColorPolicy.noColorFromEnvin ApfelCLI with unit coverage (#258). - The no-args pipe path (
echo "prompt" | apfel) now honors everyAPFEL_*environment variable and applies the model-availability gate. Previously a fast path ran beforeCLIArguments.parse()was ever called, soAPFEL_SYSTEM_PROMPT,APFEL_TEMPERATURE,APFEL_MAX_TOKENS,APFEL_MCP,APFEL_DEBUG, and theAPFEL_CONTEXT_*vars were silently dropped and an unavailable model surfaced as a classified runtime error instead of the documented exit 5. The fast path is gone; the bare pipe flows through the normal parse/dispatch path, still streaming by default to preserve its output behavior. The only remaining no-args special case is "no args and stdin is a TTY -> usage + exit 2" (#222). - Flag-like tokens placed after the prompt (
apfel summarize this --output json) now emit a stderr warning explaining that they are treated as prompt text and pointing at flag placement /--. Behavior is unchanged (still non-breaking: the whole tail is the prompt verbatim), but the previously-silent swallowing of a valid flag into the prompt is now surfaced. Detection uses the parser's ownknownFlagstable (single source of truth, no second hardcoded list) so only real flag spellings trigger it; a genuinely textual dash token like-2does not (#255). - Invalid
APFEL_*environment values now print a stderr warning instead of being silently dropped to the default while the equivalent flag hard-errors.APFEL_PORT=99999(out of range),APFEL_TEMPERATURE=abc(non-numeric/negative),APFEL_CONTEXT_STRATEGY=newest_first(typo),APFEL_MAX_TOKENS,APFEL_MCP_TIMEOUT,APFEL_CONTEXT_MAX_TURNS, andAPFEL_CONTEXT_OUTPUT_RESERVEeach emit a line likeapfel: ignoring APFEL_PORT=99999 (not in 1-65535). Warnings are collected on the parsed struct by the pureparse()(unit-testable) and printed by the executable unless--quiet. A set-but-empty var is still treated as absence, not a misconfiguration (#254). apfel --retry Nno longer silently swallows a numeric first prompt word. The optional--retrycount is genuinely ambiguous with a numeric prompt, so the next token is now consumed as the count only when it is a positive integer and at least one more token follows it:apfel --retry 7keeps "7" as the prompt with the default count, whileapfel --retry 3 "prompt"still consumes 3 as the count (backward compatible). A new--retry=Nspelling sets the count unambiguously. The ambiguity is documented in--helpand the man page (#253).apfel demos --help/apfel demos -hnow prints usage and exits 0 instead of immediately writing the nine bundled demo files into the current directory. Thedemossubcommand branch previously returned as soon as it saw thedemostoken, so every flag after it (-h,-q,-o json, unknown flags) was silently discarded. It now scans the tokens afterdemos:-h/--helpshows help, the first non-dash token is the target directory, and any other dash token is a hardunknown optionerror (exit 2) instead of being ignored (#248).- The server now validates the
Hostheader as a DNS-rebinding defense when origin checking is enabled and the bind host is loopback. Same-origin GET requests carry no Origin header, so origin checking alone could not stop a rebound attacker domain (attacker.comre-resolved to127.0.0.1) from reading/healthand/v1/models. Requests whose Host is not a loopback name (localhost/127.0.0.1/[::1], with or without a port) or the configured bind host are now rejected with 403. Legitimate localhost clients (which send Host with the port) and health checks are unaffected; a deliberately network-exposed bind (0.0.0.0) is exempt since its Host values cannot be enumerated. The host-allowlist logic is a new pureServerSecurity.isAllowedHostHeaderin ApfelCore with unit coverage (#230). - Local (stdio) MCP subprocesses now run with a scrubbed environment instead of inheriting apfel's entire environment. Previously
MCPConnectionspawned the child without settingProcess.environment, so a third-party--mcp ./tool.pyscript inheritedAPFEL_TOKEN,APFEL_MCP_TOKEN, and any cloud/API keys in the shell. The child now gets an explicit allowlist (PATH/HOME/TMPDIR/LANG, plusLC_*,PYTHON*, andVIRTUAL_ENVfor python3/FastMCP/venv servers); everyAPFEL_*var and any var whose name contains TOKEN/KEY/SECRET is excluded. The allowlist logic is a new pureServerSecurity.scrubbedMCPEnvironmentin ApfelCore with unit coverage; documented in docs/server-security.md (#229). - Binding the server to a non-loopback address (
--host 0.0.0.0,APFEL_HOST=0.0.0.0, or any LAN address) with no token now prints a loud red startup warning as prominent as the footgun warning, pointing at--token/--token-autoand docs/server-security.md. Previously it started silently with zero authentication - every host on the network could hit/v1/chat/completionswith no credentials. The server still binds (no breaking behavior change); it just warns loudly. The host-classification and warning-gate logic is a new pureServerSecurityin ApfelCore with unit coverage (#228). - The prominent multi-line red startup warning now fires whenever origin validation is disabled, not only when CORS is also enabled. Previously
--no-origin-checkalone (CORS off) turned off origin validation but showed only the mutedorigin: disabled (all origins allowed)status line; the loud "Any website can access this server and read responses!" warning was gated behind--footgun(no origin check AND CORS). The warning headline now distinguishes footgun mode from plain origin-check-disabled (#232). - The server bearer token is now compared in constant time. Previously
OriginValidator.isValidTokenused SwiftString ==, which short-circuits on the first differing byte, so comparison time correlated with the shared prefix length - a timing side channel on the token. Comparison now XOR-accumulates over the UTF-8 bytes with the length difference folded into the accumulator (no early return on length mismatch), via a new pureOriginValidator.constantTimeEqualsin ApfelCore with unit coverage (#231). - The server-side MCP auto-execute path now runs the same bounded re-detection loop as the CLI path (
maxReprompts = 3) plusstripToolCallJSONon cap exhaustion, instead of executing exactly one round and returning the follow-up content verbatim. If the model answered the tool-result follow-up with another{"tool_calls": ...}(common in tool chains), the HTTP client previously received raw tool-call JSON asmessage.contentwithfinish_reason: "stop". Chained tool calls are now executed and re-prompted, and any trailing tool-call JSON is stripped so it never leaks. The per-result truncation was extracted to a shared helper reused across rounds (#240). - Large MCP tool results are now token-budget-truncated (head+tail, with an explicit
[tool output truncated: N of M tokens shown]marker) before the follow-up prompt. A result bigger than the 4096-token window previously killed the CLI request with "Input exceeds the 4096-token context window" after the tool already ran, and in--servemode the context trimmer dropped the oversized tool message whole while still instructing the model to "answer based on the tool result above" - a confident hallucination. The budget is the input window (context size minus a 512-token output reserve) minus the prompt overhead. The truncation math lives in a new pureToolOutputTruncatorin ApfelCore with unit coverage; token counting is wired at both the CLI and server call sites, and the server path keeps the tool message present (truncated) instead of dropping it (#221). - An MCP tool result with
isError: true(e.g.divide(1, 0)returning "division by zero") is now fed back to the model as an error result so it can see the failure and recover, instead of aborting the request with HTTP 500 (--serve) or a runtime error (CLI). Per the MCP spec, execution errors "should be reported inside the result object ... so the LLM can see it and act".callTool/executenow return theToolCallResult(text + isError) instead of throwing on isError;detectAndExecuteMCPToolsrecords it like the existingtoolNotFound/invalidArgumentsbranches. Only transport/protocol failures (timeout, dead pipe, JSON-RPC error) still surface as 500 (#220). - Tool-name collisions across multiple
--mcpservers no longer silently shadow earlier servers. When two servers expose a tool with the same name, apfel now prints a loud stderr warning naming the tool and both servers and keeps the first registration (first registration wins - predictable routing, no rename surprises); the shadowed duplicate is dropped fromallTools()instead of being injected into the 4096-token prompt a second time. Previously the last-registered server won with no warning and both identical schemas were sent to the model. The dedup/collision logic is a new pureMCPToolRegistryin ApfelCore with unit coverage (#239). - A model-emitted tool call that omits (or blanks) the
"id"field is no longer dropped and leaked to the user as raw{"tool_calls":...}JSON. The parser now synthesizes acall_<8 hex chars>id when one is missing or empty, consistent with the lenient parsing that already tolerates unclosed brackets, preambles, and string-vs-objectfunctionshapes. The on-device 3B model routinely deviates from the exact prompt format, and omitting the id was the one deviation that still hard-failed (no tool executed, protocol JSON printed verbatim) (#244). - Concurrent tool calls on one local MCP connection in
--servemode no longer race on the child's stdio: the full send+receive exchange (and standalone notification writes) is now serialized per connection with a dedicated lock. Previously two simultaneous requests could interleave stdin writes (corrupting arguments larger than PIPE_BUF) and consume each other's stdout lines, delivering swapped or dropped tool results (#218). - MCP JSON-RPC responses are now correlated by
id: the stdio reader loops under one shared deadline until the response matching the request id arrives, skipping server notifications (e.g. thenotifications/messagelog lines FastMCP'sctx.info()emits), skipping responses to other ids, and answering serverpingrequests. Previously the next stdout line - whatever it was - was parsed as the response, so a single server log line failed the call with "Missing content" and left the real response buffered, desyncing every subsequent call on the connection permanently (#217). - MCP
tools/callresults with multiple text content blocks no longer lose everything after block 0 - alltype: "text"blocks are now joined with newlines. Spec-legal results that previously failed the whole request with "Missing content" now parse: an emptycontentarray is a valid empty result, a result whose content has no text blocks (e.g. an image block from a side-effect tool) falls back to serializingstructuredContent(2025-06-18 spec, theprotocolVersionapfel advertises) or an empty result. Only a response with neithercontentnorstructuredContentis rejected (#242). - Malformed model-emitted tool-call arguments (e.g. truncated JSON) are no longer silently replaced with
{}before hitting the MCP server - a tool with all-optional parameters "succeeded" with defaults and produced a confidently wrong answer with no trace. The call sites now validate the arguments first and throw a typedMCPError.invalidArguments, which is fed back to the model as a retryable tool-error result in the tool log (#241). - MCP servers are now shut down before the process exits, instead of via a fire-and-forget
defer { Task { await mcpManager?.shutdown() } }that frequently never ran - async main returned and the process exited before the unstructured Task was scheduled, so children only died on stdin EOF (a server ignoring EOF was orphaned) and on explicitexit()paths after MCP init (no-prompt exit 2,--count-tokens --strictoverflow exit 4, classified-error exits) the defer never ran at all. Shutdown is now an awaited call on every exit path and on normal completion: local children getterminate()+ a boundedwaitUntilExit()(SIGKILL escalation after a grace period) and remote connections get their sessionDELETEawaited (#246). - A timed-out MCP connection is now deregistered instead of staying permanently registered but dead. Previously, when a tool call timed out, the connection was terminated but never removed from the routing tables, so the model kept being offered the dead tool via
allTools()and every later call routed to the dead connection (a permanently broken tool with no recovery, and - before the sibling SIGPIPE fix - a process crash). apfel now removes the connection's tools from the routing map on timeout and reaps its child (SIGTERM, then SIGKILL after a bounded grace period, then a blocking wait so no zombie is left behind) (#216). - A crashed MCP server no longer kills the whole apfel process. Previously, if an MCP server exited between calls, the next tool call wrote to a pipe whose read end was closed, raising SIGPIPE (fatal by default, exit 141) and, in
--servemode, taking down the entire HTTP server - a remote/third-party MCP server could therefore crash the local process. apfel now ignores SIGPIPE process-wide and guards the stdin write with aprocess.isRunningcheck plus the throwingwrite(contentsOf:)(the legacy non-throwingFileHandle.write(_:)raised an uncatchable ObjC exception on EPIPE), mapping the failure to a recoverableMCPError.processErrorso the tool call returns a structured500and the server stays up (#215). - A
response_format: json_schemaproperty typed{"type":"number"}can now produce fractional values (e.g.{"price": 9.99},{"temperature": 0.7}). The schema IR previously conflated JSON Schemaintegerandnumberinto one case that mapped toInt, so fractional outputs were silently unreachable under schema-guided structured output. The IR now has distinct.integer(->Int) and.number(->Double) cases (#243). - JSON Schema nodes using the common nullable-optional pattern -
anyOf/oneOf[X, {"type":"null"}](any order) or a two-element"type":["string","null"]array - now parse asXwith optional semantics, instead of silently degrading to an empty (unconstrained) object schema. EveryOptional[...]field in a Pydantic/zod-generated MCP tool schema emits this pattern, so previously the model was told such tools took{}and emitted argument-less calls. Any other union (allOf, multi-type unions, type arrays without exactly onenull) now throws the parser's unsupported error, so tool conversion engages the existing text-injection fallback and aresponse_format: json_schemarequest gets an honest400instead of a200with unconstrained generation (#219). stream: truewith client-suppliedtoolsno longer leaks the raw tool-call JSON to the client asdelta.content. While tools are in play the streaming path holds back content that is still a plausible tool-call prefix and flushes it as content only once it diverges, so plain text answers still stream token-by-token while tool calls are buffered. On detection thetool_callsdelta is emitted in its own chunk (finish_reason: null) followed by a SEPARATE empty-delta chunk carryingfinish_reason: "tool_calls"(OpenAI parity), instead of the previous single chunk that bundled both. The plausible-prefix decision lives in the newStreamingToolCallGate(ApfelCore) with unit coverage (#224).stream: truewithresponse_format: {"type": "json_object"}now delivers valid JSON: the streaming path buffers the response and emits a single fence-stripped content delta (mirroring the structured-output stream), so the concatenated deltas parse directly. Previously the first delta was a```jsonfence and the joined stream was invalid JSON even though the non-streaming path was already correct (#223).- Streaming responses with
stream_options.include_usage: truenow send an explicit"usage": nullon every non-final chunk (matching OpenAI), instead of omitting the key; the single final chunk still carries the real usage stats. Without the opt-in nousagekey is emitted at all (#238). - An invalid
tool_choice(an unrecognized string like"banana"or an undecodable object) now returns400 invalid_request_errorinstead of being silently coerced toauto.ToolChoicedecodes such values to a new.invalidcase that the validator rejects (#238). - An unknown
x_context_strategyvalue (e.g.sliding-windowtypo'd assliding_window) now returns400 invalid_request_errorlisting the valid values, instead of silently falling back tonewest-firstwhile the caller believes their strategy is active. The siblingx_context_max_turns/x_context_output_reserveparams were already strictly validated (#237). - The OpenAI error object now always includes
paramandcode(explicitnullwhen absent), so router/proxy front-ends that branch onerror.codesee the key. An unknownmodelnow returns404withcode: "model_not_found"andparam: "model"(OpenAI parity) instead of400with the keys omitted (#236). top_poutside[0, 1]andtemperatureabove2now return400 invalid_request_errorinstead of passing through to FoundationModels and surfacing as an opaque500. The existingtemperature < 0check is unchanged; OpenAI capstemperatureat 2 and requirestop_pin[0, 1](#235).- A
/v1/chat/completionsrequest body over 1 MiB now returns413with an OpenAI error object, CORS headers, and a request-log entry, instead of a bare413withContent-Length: 0(no error object, unreadable by browser clients, unlogged). The over-limitcollecterror is caught inside the handler and returned as a normal response so the CORS middleware and request logger both run (#234). - Empty or null
contentin the last (non-tool) user message of a/v1/chat/completionsrequest now returns400 invalid_request_error("The last message must have non-empty 'content'") instead of500 server_error. A missing prompt is a client-input problem, not a server fault (#233). - Streaming requests that fail before the SSE body is built (validation failure, bad
json_schema, context-build failure) no longer leak a concurrency permit and anactive_requestscount. Previously--max-concurrent(default 5) malformed"stream": truerequests permanently exhausted server capacity - a remote unauthenticated DoS. Cleanup is now keyed on an explicitownsCleanuptrace flag set only by live SSE stream responses, instead of on the requestedstreamvalue (#213). - A request that waits the full 30s for a concurrency permit no longer crashes the whole server with SIGABRT ("freed pointer was not the last allocation"); it now gets the intended 429. The semaphore timeout task no longer uses the clock-based
Task.sleep(for:)(which aborted the task allocator on resume under the server executor) and is now stored on the actor and cancelled bysignal()when a permit is handed over.AsyncSemaphoremoved intoApfelCorefor unit-test coverage (#214).
apfel --count-tokens- zero-inference token-budget preflight. Reports how many tokens a prompt would consume before calling the on-device model, broken down by prompt/system/file/MCP component against the context budget. Accepts the same inputs as prompt mode (stdin,-f,-s,--system-file,--mcp), supports-o jsonfor a machine-readable breakdown, and--strict(exit 4 when over budget). Runs even when Apple Intelligence is unavailable via a chars/4 fallback (approximate: true) (#207).
- Tap formula no longer prints Homebrew 6's
depends_on :macoswithdepends_on macos:runtime deprecation on everybrewoperation. The macOS version floor moved into anon_macosblock (as Homebrew's deprecation message prescribes) while the bare top-leveldepends_on :macos- the only hard Linux block for the prebuilt-binary tap - is preserved (#206). message_text_contentbenchmark no longer flakes the release preflight. It is a single-pass correctness refactor with no reliably measurable speedup, so the performance test now validates its output rather than asserting a wall-clock speedup ratio it cannot stably deliver.
apfel demos [dir]writes the bundled demo scripts (cmd, explain, oneliner, wtd, naming, port, gitsum, mac-narrator) to a directory. The demos are embedded in the binary, so it behaves identically on homebrew-core, the tap, and source builds (#204).
- CHANGELOG.md is now backfilled through every release and kept current automatically by the release workflow (#201).
- Tap formula keeps its macOS-only guard: silence the
Homebrew/OSDependsOnstyle warning without droppingdepends_on :macos, which is the only hard Linux block for the prebuilt-binary tap (#203).
- Handle function-name string tool calls (#200).
- Zero-touch nixpkgs distribution via r-ryantm + merge bot.
- Strict context strategy no longer duplicates the final prompt.
- Support the standard
--end-of-options separator. - Blank line in MCP reader leftover no longer stalls into a timeout.
- Repair unclosed bracket in model tool call JSON (#187).
- Removed the
apfel tagsubcommand - feature creep, moved to sister tool https://github.com/Arthur-Ficial/apfel-tag.
APFEL_DEBUGenv var enables debug logging (#164).
- Bump hummingbird to 2.25.0 (#162).
- Native
response_formatjson_schema via DynamicGenerationSchema (#167). - Honor
top_p(nucleus sampling) and maketemperature:0deterministic via.greedy(#168). - Model prewarm at startup,
/healthreports "prewarmed" (#169).
- Bound summary tokens and verify assembled transcript fits budget (#175).
- Count pre-refusal streamed content in
completion_tokens(#179). - Print streamed output once across retries (#182).
- String-aware brace scan + bounded CLI re-detection (#178).
- Fallback token counter counts tool definitions and tool-call args (#176).
- Unknown
GenerationErrorcase classifies to.unknown, not a locale keyword guess (#181). SchemaParserthrows on non-dictionary property schema instead of silently dropping it (#180).- Env vars and
--retryenforce the same validation as their flags (#177). JSONFenceStripper.stripreturns trimmed content when no fence present (#183).
--context-statusflag to show context fill after each turn (#157).
- Ship
demo/scripts asapfel-<name>companion commands in Homebrew (#155).
- Detect MacPorts install on
--update(#151).
- Bump hummingbird dependency.
- Warn when piped stdin is empty (#152).
- Auto-bump nixpkgs as final step of
make release. - Zed agent panel integration guide.
- Use text-only tool instructions to prevent native interception (#144).
- Bump swift-docc-plugin from 1.4.6 to 1.5.0.
- Bump hummingbird dependency.
- Graceful
finish_reason=length; drop arbitrary 1024 default (#136).
- CLI/server parity for
max_tokensdefault and--serve --permissive(#130).
- Apply default
max_tokenswhen client omits the field (#128).
- Return 200 OK +
content_filterfor on-device refusals instead of 500 (#118).
- Cache static model metadata at startup to avoid
/healthcold-start timeout and mid-flight SDK crash (#125).
- TDD coverage for
ApfelError.refusal+ extractexitCodemapping into ApfelCLI (#124).
- Preserve refusal explanation via
ApfelError.refusal(String)(#120).
- Extract FoundationModels
GenerationErrorclassification into typed enum (#117).
- Reframe golden goal in README, trim Swift library content to a single link per CLAUDE.md structure rule.
ApfelCoreexposed as a public Swift Package library product (#114, #105).- Downstream-consumer smoke coverage for importing
ApfelCorefrom another package. - DocC catalog, examples, and package metadata for
ApfelCore.
- Stop regenerating
BuildInfo.swifton every local build (#108).
- Replace the unsafe global debug flag with
ApfelDebugConfiguration. - Serialize same-reader
BufferedLineReaderaccess so the type is safelySendable. - Narrow package-only streaming and prompt-processing helpers out of the public semver surface.
apfel(1)man page with drift-prevention (#103).
- Scripting-language guides for Python, Node.js, Ruby, PHP, Bash, Zsh, AppleScript, Swift, Perl, and AWK.
- Gate streaming usage chunk on
stream_options.include_usage. - Strip markdown fence from
json_objectoutput.
- Extract pure modules from
Handlers.swift; add unit tests (#98).
- PR auto-review routine with hard guardrails (#89).
- Automate nixpkgs version bumps (#86).
make installcreates missingPREFIX/bin, build cache stable (#84, #83).
- Extract pure
SchemaIR+SchemaParserfromSchemaConverter(#94).
make test- single command for all tests.
- Read piped stdin in
--streammode (#82). - Harden release process and
make installPATH handling.
First stable release. CLI flags, exit codes, API endpoints, and response schemas are now semver-protected (see STABILITY.md).
- Stable release contract under semantic versioning.
- Full release qualification gate (362 unit + 157 integration tests).
- Security policy (SECURITY.md).
brew install apfelvia homebrew-core.
For pre-1.0 release history, see https://github.com/Arthur-Ficial/apfel/releases.