fix(apple-fm): real on-device generation (programmatic schema) + dlopen packaging so npm arm64 never crashes on macOS <26 - #726
Conversation
…vs-heuristic provenance The structured-response callback received a +1-retained FMGeneratedContentRef on every path (success and error) but only freed the JSON string, leaking one generated-content wrapper per generation — unbounded in session count. Release the content handle on both paths. Also cap first_user_message at 1000 chars (the on-device context window is small and an oversized pasted message risks truncation/refusal/latency), log the status/timeout cause when a generation degrades to the heuristic, and print a "(M via Apple FM, N-M heuristic)" breakdown so a silent total-fallback is visible. Added an #[ignore]d live smoke test documenting the on-device path and a module note that fm-c-example's streaming binary segfaults on macOS 26.2. Constraint: callback owns the content +1 (FMGeneratedContentGetJSONString only borrows it) Constraint: on-device FM context window is small; prompts must be bounded Rejected: match the CLI's 200-char cap | FM prompt carries one session, can afford 1000 Rejected: tune SYSTEM_INSTRUCTIONS/build_prompt now | no real FM generations to tune against yet; deferred to maintainer Confidence: high Scope-risk: narrow Directive: the structured_callback must FMRelease(content) on EVERY exit path or it leaks per generation Not-tested: live on-device generation (live_summarize_smoke stays #[ignore]d in CI)
…ally generates The summarizer passed a standard JSON-Schema string to FMLanguageModelSessionRespondWithSchemaFromJSON. The vendored shim decodes that via JSONDecoder().decode(GenerationSchema.self, ...), which expects Apple's private serialized GenerationSchema dialect, not JSON Schema, so it threw NSCocoaErrorDomain:4865 BEFORE generation -> status 255 -> heuristic fallback 100% of the time (fm_version was always null). Switch to the programmatic builder (FMGenerationSchemaCreate / ...PropertyCreate / ...AddAnyOfGuide / ...AddProperty) feeding FMLanguageModelSessionRespondWithSchema, mirroring the path the vendor tests use (DynamicGenerationSchema). The category/complexity enums are enforced on-device via unwrapped anyOf guides. Verified live on macOS 26.2 / arm64: both probe sessions now return fm_version=apple-fm-on-device with real model-generated titles and correct categories (feature, bugfix). Constraint: shim maps typeName via 'case "string":' (lowercase) and only accepts unwrapped anyOf guides for scalar String properties Rejected: keep ...FromJSON with corrected JSON | shim requires Apple's private GenerationSchema dialect, not standard JSON Schema Confidence: high Scope-risk: narrow Directive: typeName MUST stay lowercase 'string' and anyOf wrapped flag MUST stay false; other values fall through to the shim's reference-schema branch / unsupportedGuide and break schema construction Not-tested: generation exceeding the 60s timeout path
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…summarize progress apple-fm (and any non-CLI backend) had no grouping path: run_task_grouping printed "skipped" and left every task_group null, so print_report_table collapsed sessions by EXACT title — a degenerate Task Group table full of near-duplicate one-session rows. Replace the skip with deterministic Rust-side title clustering: normalize titles to significant tokens (lowercase, strip punctuation/ellipsis, drop generic verbs/stopwords), greedily cluster by token overlap (Jaccard >= 0.6 or >= 2 shared tokens), then run a fixpoint consolidation pass so the result is order-independent. Each cluster is labeled with its most-frequent original title (tie-broken by shortest). Also fix apple-fm summarize progress: batch_size was payloads.len() (one giant chunk) which suppressed the per-batch '\r Batch i/total' indicator. Use a modest batch size of 8 so 100+ sequential on-device generations show visible progress; fm-vs-heuristic counts still accumulate across batches. Constraint: small on-device model — no second array-output LLM call for grouping (fragile); cluster deterministically in Rust instead Rejected: synthetic token-set keys as group labels | unreadable; use real most-frequent title Confidence: high Scope-risk: narrow Directive: clustering thresholds (Jaccard 0.6 / 2 shared tokens) and CLUSTER_STOPWORDS are tuned for short imperative titles — re-check the unit tests if you change them Not-tested: behavior when on-device FM emits titles in a non-Latin script (tokenizer is alphanumeric-only)
|
Added two report improvements on top of the schema fix (commit 1. Real task grouping for the apple-fm backend. Previously Near-duplicate titles merge into one group; unrelated ones stay separate. (CLI backends keep their LLM grouping; semantic FM array-output grouping was deliberately avoided — fragile on a small on-device model.) Unit-tested. 2. Summarization progress for apple-fm. Note: the earlier-suspected Verified: default + |
There was a problem hiding this comment.
2 issues found across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…er crashes on macOS <26 The published cli-darwin-arm64 binary ships to EVERY Apple Silicon Mac, but was built --features apple-fm, hard-linking FoundationModels.framework (macOS 26+) and macOS-26-SDK Swift runtime (libswiftSynchronization, 15+). otool confirmed LC_LOAD_DYLIB (not weak), so dyld would abort at launch on macOS 14/15 — a crash-on-launch for EVERY command (report included), not a feature fallback. And `import FoundationModels` autolinks the framework as a non-weak load command, so a build.rs -weak_framework flag can't reliably flip it. Build the vendored shim as a DYNAMIC libFoundationModels.dylib (Package.swift already exposes the .dynamic product) and dlopen/dlsym it at runtime instead of linking. The tokscale binary now links NOTHING FM/Swift — verified: otool -L target/release/tokscale => only AppKit/Foundation/CoreGraphics/ CoreFoundation/libobjc/Security/libiconv/libSystem (all present on macOS 11+). A sysctl kern.osproductversion >= 26 gate plus graceful dlopen-failure means older macOS / non-26 silently uses the Rust heuristic; the dylib (and its FM + Swift deps) only load on macOS 26 where they exist. Runtime dylib lookup: current_exe()'s dir (npm package + `cargo run`) with a build-time OUT_DIR fallback baked in for `cargo test` (harness binary lives in deps/). build.rs stages the dylib next to the binary; CI copies it into the package bin/ so it travels alongside tokscale. Verified on macOS 26 (Apple Intelligence on): - release + debug binaries: zero FM/swift load commands (otool), minos 11.0 - live smoke test: dlopen ok, real FM generation, fm_version=apple-fm-on-device - npm-equivalent (copied binary in arbitrary dir, run by abs path, ALL fallbacks hidden): `dlopen ok .../libFoundationModels.dylib` + 1 via Apple FM - fmt + clippy (feature on & off) + full suite (678 + 122) green TOKSCALE_FM_DEBUG=1 traces the OS gate / dlopen path / symbol resolution for field diagnosis of heuristic fallbacks. Constraint: one npm binary per (os, cpu) — can't select by macOS version, so the arm64 binary must be safe on every macOS it reaches Rejected: drop --features apple-fm from publish (npm heuristic-only) | user wants the feature shipped in npm Rejected: weak_framework + weak Swift runtime | autolink re-adds them as hard loads; not reliably flippable from the Rust side Rejected: keep static link | hard FoundationModels + libswiftSynchronization => dyld crash-on-launch on macOS <26 Confidence: high Scope-risk: narrow Directive: the tokscale binary must keep ZERO FoundationModels/libswift* load commands — re-verify `otool -L` if you touch build.rs linking; a hard link reintroduces the macOS<26 crash Not-tested: actual macOS 14/15 hardware (no access) — safety rests on the otool load-command invariant, which is the dyld contract
…cOS 26+), heuristic fallback elsewhere Sync EN/ja/ko/zh: the prebuilt arm64 binary now bundles apple-fm and is safe to run on every macOS, so the backend note no longer implies a source build.
…746) * docs: sync EN/ja/ko/zh-cn for unreleased clients + breaking flag removal Pre-v3.2.0 documentation sync for the unreleased range (v3.1.3..main): - #465: replace the now-false legacy per-client flag notices in ja/ko/zh-cn (they claimed the removed flags still work) with a v3.2.0 breaking-change migration note; add the same note to README.md (which had none). - #728: document the MiniMax Token Plan subscription source (distinct from the MINIMAX_API_KEY row) in all locales; port the entire Subscription Usage section into ja/ko/zh-cn (was English-only). - #718: add the Jcode table row + detail section to the locales missing them. - #726: document the TOKSCALE_FM_DEBUG env var in all locales. - #633: add the missing task-attributed report bullet to README.ja Key Features. - drift: add Junie to the frontend Source-filtering list (all locales). - #710: fix the MiMo Code repo link (XiaomiMiMo/MiMo -> XiaomiMiMo/MiMo-Code). - #717: disclose Command Code token usage is estimated (~4 chars/token). Confidence: medium Scope-risk: narrow Directive: ja/ko/zh-cn translations of the ported Subscription Usage section are machine-generated and should get a native-speaker review pass Not-tested: #713 Antigravity CLI detail section was not added — no English source section exists to port from * fix(report): feed real session content to the summarizer (#633) extract_content_for_session unconditionally returned metadata_only_content() (first_user_message hardcoded None), so the report summarizer never saw any conversation content and the four real per-client extractors were dead code. Add content_extractor::extract_session_content, which dispatches to the correct per-client extractor (opencode/claude/codex/gemini) and falls back to metadata-only — never erroring or panicking — for unknown clients, missing candidates, or unreadable/unparseable files. report.rs builds a SessionPathIndex once (session_id -> transcript file, plus opencode DBs) and threads it through run_summarizer so each payload carries the real first user message. Confidence: high Scope-risk: moderate Rejected: thread file paths through core's scanner/WikiEntry | too invasive; indexed at the report layer instead Not-tested: end-to-end opencode/codex/gemini extraction in report.rs (core dispatcher covers claude + all fallback paths; per-client extractors are pre-existing) * docs: name the breaking release v4.0.0 (was v3.2.0) The per-client flag removal (#465) is a breaking change, so the next release is v4.0.0, not v3.2.0. Update the migration notes in all four README locales and the main.rs doc comment accordingly. * fix(report): real Codex/Gemini extraction + (client,session_id) index keying Addresses automated review feedback on the #633 report-summarizer-content fix (PR #746). The summarizer still surfaced (none) for normal Codex/Gemini sessions and could mis-route cross-client session_id collisions. - content_extractor: parse the current on-disk Codex format (event_msg with payload.type == "user_message", text in payload.message) and skip harness-injected context blocks (<environment_context>/<system-reminder>/ <user_instructions>), mirroring sessions::codex. - content_extractor: Gemini extractor now handles chat-recording JSON (messages[].type == "user" / content) and falls back to scanning line-delimited JSONL; empty/whitespace user text is treated as not-found. - extract_session_content: an empty/whitespace first_user_message no longer counts as success, so scanning continues to a later candidate with real text. - report: SessionPathIndex is keyed by (client, session_id) to prevent cross-client collisions, and Gemini files are keyed by their in-file sessionId (via gemini_session_id_for_file) rather than the filename stem, since the wiki entry's session_id is derived from inside the file. - Added fixture-based regression tests for all of the above. Constraint: wiki session_id for Gemini comes from the in-file sessionId, not the path stem Rejected: match any leading '<' for Codex injected blocks | drops legit prompts starting with markup Confidence: high Scope-risk: narrow
…en packaging so npm arm64 never crashes on macOS <26 (junhoyeo#726) Two fixes that make the apple-fm `report` summarizer actually work and ship safely: - Programmatic GenerationSchema: the prior `...RespondWithSchemaFromJSON` path threw NSCocoaErrorDomain:4865 (plain JSON Schema decoded as Apple's private serialized dialect), so every generation silently fell back to the heuristic. Build the schema via FMGenerationSchemaCreate/PropertyCreate/AddAnyOfGuide instead -> FM now generates real titles (fm_version=apple-fm-on-device). - dlopen instead of linking: FoundationModels.framework (macOS 26+) and the shim's Swift runtime were hard-linked, so the npm arm64 binary would dyld-crash at launch on macOS 14/15. The shim is now built as a dynamic libFoundationModels.dylib and dlopen'd lazily (sysctl >=26 gate + graceful fallback); the binary links nothing FM/Swift (otool-verified). One arm64 binary safe on every macOS; older/non-Apple uses the Rust heuristic. Also: real task-grouping for apple-fm via title clustering + summarize progress; a guard so blank titles don't persist empty task-group labels; README EN/ja/ko/zh sync. TOKSCALE_FM_DEBUG traces the dlopen path.
…en packaging so npm arm64 never crashes on macOS <26 (junhoyeo#726) Two fixes that make the apple-fm `report` summarizer actually work and ship safely: - Programmatic GenerationSchema: the prior `...RespondWithSchemaFromJSON` path threw NSCocoaErrorDomain:4865 (plain JSON Schema decoded as Apple's private serialized dialect), so every generation silently fell back to the heuristic. Build the schema via FMGenerationSchemaCreate/PropertyCreate/AddAnyOfGuide instead -> FM now generates real titles (fm_version=apple-fm-on-device). - dlopen instead of linking: FoundationModels.framework (macOS 26+) and the shim's Swift runtime were hard-linked, so the npm arm64 binary would dyld-crash at launch on macOS 14/15. The shim is now built as a dynamic libFoundationModels.dylib and dlopen'd lazily (sysctl >=26 gate + graceful fallback); the binary links nothing FM/Swift (otool-verified). One arm64 binary safe on every macOS; older/non-Apple uses the Rust heuristic. Also: real task-grouping for apple-fm via title clustering + summarize progress; a guard so blank titles don't persist empty task-group labels; README EN/ja/ko/zh sync. TOKSCALE_FM_DEBUG traces the dlopen path.
…unhoyeo#746) * docs: sync EN/ja/ko/zh-cn for unreleased clients + breaking flag removal Pre-v3.2.0 documentation sync for the unreleased range (v3.1.3..main): - junhoyeo#465: replace the now-false legacy per-client flag notices in ja/ko/zh-cn (they claimed the removed flags still work) with a v3.2.0 breaking-change migration note; add the same note to README.md (which had none). - junhoyeo#728: document the MiniMax Token Plan subscription source (distinct from the MINIMAX_API_KEY row) in all locales; port the entire Subscription Usage section into ja/ko/zh-cn (was English-only). - junhoyeo#718: add the Jcode table row + detail section to the locales missing them. - junhoyeo#726: document the TOKSCALE_FM_DEBUG env var in all locales. - junhoyeo#633: add the missing task-attributed report bullet to README.ja Key Features. - drift: add Junie to the frontend Source-filtering list (all locales). - junhoyeo#710: fix the MiMo Code repo link (XiaomiMiMo/MiMo -> XiaomiMiMo/MiMo-Code). - junhoyeo#717: disclose Command Code token usage is estimated (~4 chars/token). Confidence: medium Scope-risk: narrow Directive: ja/ko/zh-cn translations of the ported Subscription Usage section are machine-generated and should get a native-speaker review pass Not-tested: junhoyeo#713 Antigravity CLI detail section was not added — no English source section exists to port from * fix(report): feed real session content to the summarizer (junhoyeo#633) extract_content_for_session unconditionally returned metadata_only_content() (first_user_message hardcoded None), so the report summarizer never saw any conversation content and the four real per-client extractors were dead code. Add content_extractor::extract_session_content, which dispatches to the correct per-client extractor (opencode/claude/codex/gemini) and falls back to metadata-only — never erroring or panicking — for unknown clients, missing candidates, or unreadable/unparseable files. report.rs builds a SessionPathIndex once (session_id -> transcript file, plus opencode DBs) and threads it through run_summarizer so each payload carries the real first user message. Confidence: high Scope-risk: moderate Rejected: thread file paths through core's scanner/WikiEntry | too invasive; indexed at the report layer instead Not-tested: end-to-end opencode/codex/gemini extraction in report.rs (core dispatcher covers claude + all fallback paths; per-client extractors are pre-existing) * docs: name the breaking release v4.0.0 (was v3.2.0) The per-client flag removal (junhoyeo#465) is a breaking change, so the next release is v4.0.0, not v3.2.0. Update the migration notes in all four README locales and the main.rs doc comment accordingly. * fix(report): real Codex/Gemini extraction + (client,session_id) index keying Addresses automated review feedback on the junhoyeo#633 report-summarizer-content fix (PR junhoyeo#746). The summarizer still surfaced (none) for normal Codex/Gemini sessions and could mis-route cross-client session_id collisions. - content_extractor: parse the current on-disk Codex format (event_msg with payload.type == "user_message", text in payload.message) and skip harness-injected context blocks (<environment_context>/<system-reminder>/ <user_instructions>), mirroring sessions::codex. - content_extractor: Gemini extractor now handles chat-recording JSON (messages[].type == "user" / content) and falls back to scanning line-delimited JSONL; empty/whitespace user text is treated as not-found. - extract_session_content: an empty/whitespace first_user_message no longer counts as success, so scanning continues to a later candidate with real text. - report: SessionPathIndex is keyed by (client, session_id) to prevent cross-client collisions, and Gemini files are keyed by their in-file sessionId (via gemini_session_id_for_file) rather than the filename stem, since the wiki entry's session_id is derived from inside the file. - Added fixture-based regression tests for all of the above. Constraint: wiki session_id for Gemini comes from the in-file sessionId, not the path stem Rejected: match any leading '<' for Codex injected blocks | drops legit prompts starting with markup Confidence: high Scope-risk: narrow
The bug (in the just-merged #721)
The
apple-fmsummarizer never actually ran on-device — every session silently fell back to the Rust heuristic (fm_version: null). Found via live validation now that Apple Intelligence is enabled on the build machine.Root cause:
apple_fm.rspassed a plain JSON Schema string toFMLanguageModelSessionRespondWithSchemaFromJSON, but the vendored shim decodes that viaJSONDecoder().decode(GenerationSchema.self, …), which expects Apple's private serializedGenerationSchemadialect, not standard JSON Schema. It threwNSCocoaErrorDomain:4865before generation → callback status 255 →respond_onereturnedNone→ heuristic. (This is also why Apple's own shim tests build schemas programmatically viaDynamicGenerationSchema, never via the FromJSON string path.)The fix
Switch to the programmatic schema builder (
FMGenerationSchemaCreate/FMGenerationSchemaPropertyCreate/FMGenerationSchemaPropertyAddAnyOfGuide/FMGenerationSchemaAddProperty) +FMLanguageModelSessionRespondWithSchema. DropSCHEMA_JSONand the FromJSON path. Thetask_category/complexityenums are now constrained on-device via anyOf guides (withparse_summary/normalize_*still as the post-hoc safety net). Also: log a one-line warning on a non-zero FM status (no more fully-silent fallback), and report.rs now counts FM-generated vs heuristic summaries.Live proof (macOS 26.2 / arm64, Apple Intelligence on)
cargo test --features apple-fm live_summarize_smoke -- --ignored --nocapture:Real model-generated titles/categories (not heuristic "Work on "),
fm_versionnow correct. A#[ignore]dlive_summarize_smoketest documents this path (skipped in CI).Verified: default (cross-platform stub) build,
--features apple-fmbuild, clippy, fmt all clean; non-ignored tests pass.Deferred (left for follow-up — non-blocking)
Sequential per-session generation latency (no batching/progress yet); these are tracked but out of scope here.
🤖 Generated with Claude Code
Summary by cubic
Make
apple-fmactually run on-device with a programmatic schema, and loadlibFoundationModels.dylibat runtime so the prebuilt Apple Silicon npm binary works on all macOS versions and cleanly falls back when Apple Intelligence isn’t available. Docs now noteapple-fmships in the arm64 binary; the report shows FM vs heuristic counts and groups sessions via deterministic title clustering.Bug Fixes
FMLanguageModelSessionRespondWithSchema; remove the JSON path that forced heuristic fallback. Enforce enums withanyOf.libFoundationModels.dylibviadlopen(macOS ≥ 26 only); if missing/unavailable, degrade to the heuristic. Do not link FM/Swift into the binary. Stage the dylib next to the binary and include it in the npm artifact.FMGeneratedContentin the callback to fix one-handle-per-generation leaks.New Features
apple-fmsessions by deterministic title clustering; label groups by the most frequent title.apple-fm(batch size 8).apple-fm.Written for commit 097ea7f. Summary will update on new commits.