diff --git a/changelogs/v2.4.7.md b/changelogs/v2.4.7.md new file mode 100644 index 000000000..45bff4c22 --- /dev/null +++ b/changelogs/v2.4.7.md @@ -0,0 +1,12 @@ +## What's new + +Device Sync now keeps each workspace's changes in its own file, so several workspaces can share one sync folder without mixing. + +### Workspace-scoped sync + +- **Sync files are now per-workspace**: Each device now publishes its Device Sync changelog as `oplog--.ndjson` instead of a single per-device file. The workspace identifier in the filename marks which *source* the changes belong to, so a device only reads the files for its own workspace and ignores the rest. This is the groundwork for the mobile app to hold several workspaces in one shared folder and switch between them, while keeping each workspace's data cleanly separated — one workspace's notes and tasks can never leak into another that happens to share the same folder. +- **Nothing to do on the desktop**: The change is transparent. Your desktop stays on its single workspace; on the first sync after updating it simply republishes its full state under the new filename. Older leftover sync files are ignored and can be removed from the folder if you want to tidy up. + +### Under the hood + +- The sync transport validates both the device and workspace identifiers in each filename and matches files by an exact workspace suffix (never by naively splitting the name), so identifiers containing dashes can't misroute a read. Legacy unsuffixed sync files remain readable for a smooth transition. diff --git a/docs/plans/apple-foundation-models-provider.md b/docs/plans/apple-foundation-models-provider.md index e46fbd05d..0484dd2c4 100644 --- a/docs/plans/apple-foundation-models-provider.md +++ b/docs/plans/apple-foundation-models-provider.md @@ -1,9 +1,61 @@ # Plan: On-device / Private Cloud Compute Apple Intelligence provider -**Status:** shelved. On-device (iOS 26) implementation exists in the repo but is -**not viable** for Cairn's agentic tool use. Re-implement against **Private Cloud -Compute (PCC)** once the managed entitlement is granted and an **iOS 27 SDK** is -available. +**Status:** PCC IMPLEMENTED (2026-07-08). Private Cloud Compute is now the +user-facing "Apple Intelligence" provider (full tool set, full system prompt, +32K window, `moderate` reasoning, quota UI). The on-device (iOS 26) model is +retained but DEV-ONLY behind `EXPO_PUBLIC_APPLE_LLM_DEV` (too weak for agentic +tool use). Verified: JS type-check + lint clean; Swift parses clean under BOTH +the stable iOS-26 SDK (PCC compiled out) and the beta iOS-27 SDK (PCC compiled +in). Pending device verification on iOS 27 with the granted entitlement. + +### EAS-safety: the CAIRN_PCC_SDK compile-time gate (critical) + +PCC symbols (`PrivateCloudComputeLanguageModel`, `ContextOptions.ReasoningLevel`, +`.quotaLimitReached`, `quotaUsage`) exist ONLY in the iOS 27 SDK. `#available(iOS +27)` is a RUNTIME check — it does NOT stop the compiler needing those symbols to +exist. `#if canImport(FoundationModels)` is TRUE on both SDK 26 and 27 (only the +PCC types within it are new), so it can't gate them either. The current EAS iOS +image is `macos-tahoe-26.4-xcode-26.4` (iOS 26 SDK) — referencing PCC symbols +unconditionally there FAILS the build. + +Fix (`modules/apple-llm/ios/AppleLlm.podspec`): read the active iOS SDK version +at pod-eval time (`xcrun --sdk iphoneos --show-sdk-version`) and define the +`CAIRN_PCC_SDK` compilation condition ONLY when the SDK major is ≥ 27. ALL PCC +symbol references live inside `#if CAIRN_PCC_SDK`. Result: +- EAS / stable Xcode 26 (iOS 26 SDK) → flag off → PCC compiled out → builds green, + PCC reports unavailable at runtime. +- Local Xcode 27 beta (`/Applications/Xcode-beta.app`, iOS 27 SDK) → flag on → + PCC compiled in + runs. Point the toolchain at it: + `sudo xcode-select -s /Applications/Xcode-beta.app/Contents/Developer` + (or prefix a build with `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer`). +- Future EAS iOS 27 image → flips on automatically, zero code change. + +### SDK API gotcha: LanguageModelSession init + `LanguageModel` are iOS-27-only + +Verified against the iOS 27 SDK `.swiftinterface` (build error surfaced it): +- The **unified** `LanguageModelSession.init(model: some LanguageModel, tools:, instructions:)` and the `LanguageModel` protocol itself are **iOS 27.0+**. Passing a `PrivateCloudComputeLanguageModel` REQUIRES this init. +- iOS 26 only has the **concrete** `init(model: SystemLanguageModel = .default, tools:, instructions:)`. +- So there is NO single shared init/`resolveModel(): any LanguageModel` (that annotation compiled under `@available(iOS 26)` but references 27-only symbols → error). `ensureSession` must branch: server path (`#if CAIRN_PCC_SDK` + `#available(iOS 27)`) builds with `PrivateCloudComputeLanguageModel()` via the `some LanguageModel` init; on-device path uses the concrete-`SystemLanguageModel` init. +- Likewise `streamResponse(to:options:contextOptions:)` is iOS 27+ (base `streamResponse(to:options:)` is iOS 26); reasoning is applied only on the server path. +- Quota/availability shapes confirmed: `PrivateCloudComputeLanguageModel()`, `.availability → .available/.unavailable(.deviceNotEligible|.systemNotReady)`, `.quotaUsage.status → .belowLimit(BelowLimit{isApproachingLimit})`, `.isLimitReached`, `.limitIncreaseSuggestion?.show()`, `.resetDate`, `Error.quotaLimitReached(_)`. +- **Verified:** AppleLlm pod `xcodebuild BUILD SUCCEEDED` under iOS 27 SDK (flag on); parses clean under iOS 26 SDK (flag off). + +### Entitlement gating (critical) + +`com.apple.developer.private-cloud-compute` is a MANAGED entitlement +without it being provisioned for the App ID BREAKS code-signing on every build. +`plugins/withPrivateCloudCompute.js` therefore injects it ONLY when +`EXPO_PUBLIC_PCC=1`. Set that flag only once the entitlement is granted AND you +build the iOS 27 SDK variant. The app runs fine without it (PCC just reports +unavailable), so shipped/EAS builds stay signable. + +--- + +## Original shelving context (on-device) + +On-device (iOS 26) implementation exists but is **not viable** for Cairn's +agentic tool use. Re-implemented against **Private Cloud Compute (PCC)** — see +status above. --- diff --git a/electron/sync/desktop-sync.ts b/electron/sync/desktop-sync.ts index 2bbf81ba7..41252f4dc 100644 --- a/electron/sync/desktop-sync.ts +++ b/electron/sync/desktop-sync.ts @@ -74,6 +74,20 @@ export function resetDesktopEngine(): void { _engine = null; } +/** + * The workspace this desktop syncs as its SOURCE. The desktop is single-source: + * it takes the first/oldest workspace (matching main.ts boot resolution). Used + * as the `` suffix on the oplog filename so many devices AND + * workspaces can share one folder while each reader selects only its workspace. + * Returns "" if no workspace exists yet (falls back to legacy unsuffixed name). + */ +function getSourceWorkspaceId(db: Database.Database): string { + const row = db.prepare("SELECT id FROM workspaces ORDER BY created_at LIMIT 1").get() as + | { id?: string } + | undefined; + return row?.id ?? ""; +} + // ── sync-folder persistence (in sync_state) ───────────────────────────────── const FOLDER_KEY = "sync_folder_path"; @@ -127,6 +141,8 @@ export async function syncDesktop(db: Database.Database, projectNote?: NoteFileP } const engine = getDesktopEngine(db); const project = projectNote ?? _projector; + // The workspace this desktop publishes as its source (filename suffix). + const workspaceId = getSourceWorkspaceId(db); // First-run backfill so the phone receives the whole existing workspace. const seeded = engine.backfill(); @@ -134,13 +150,13 @@ export async function syncDesktop(db: Database.Database, projectNote?: NoteFileP const drained = engine.drainPending(); // Publish our full oplog. Folder I/O is async so a slow/network-backed folder // (iCloud/Dropbox) doesn't block the Electron main loop. - await writeOplogFileAsync(folder, engine.deviceId, engine.exportOplog()); - // Read + reconcile peers. - const peerEntries = await readPeerOplogsAsync(folder, engine.deviceId); + await writeOplogFileAsync(folder, engine.deviceId, engine.exportOplog(), workspaceId); + // Read + reconcile peers — only files for OUR workspace (source isolation). + const peerEntries = await readPeerOplogsAsync(folder, engine.deviceId, workspaceId); const { conflictCopies, applied } = engine.applyRemote(peerEntries); // Re-publish if reconcile forwarded peer ops / minted conflict copies. if (peerEntries.length > 0) { - await writeOplogFileAsync(folder, engine.deviceId, engine.exportOplog()); + await writeOplogFileAsync(folder, engine.deviceId, engine.exportOplog(), workspaceId); } // Project inbound note changes onto disk (.md dual-write parity). Conflict diff --git a/mobile/.env.example b/mobile/.env.example index d8ef125c5..d9908c5b8 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -40,14 +40,28 @@ EXPO_PUBLIC_TOOLKIT_URL= # ───────────────────────────────────────────────────────────────────────────── # Apple on-device AI (experimental, dev-only) # ───────────────────────────────────────────────────────────────────────────── -# The Apple Foundation Models (on-device Apple Intelligence) provider is -# EXPERIMENTAL and not yet viable for Cairn's agentic tool use — the on-device -# model is too small / context too tight (see -# docs/plans/apple-foundation-models-provider.md; the plan is to redo it against -# Private Cloud Compute on iOS 27). It is therefore HIDDEN from end users. -# -# Set to 1 in your LOCAL .env.local to expose the "On-device" provider in -# Settings → AI so you can keep iterating on a device. Leave BLANK/unset for EAS -# and any shipped build — the provider then never appears or resolves. (The -# native module still compiles but stays dormant unless this flag is on.) +# The on-device Apple Foundation Models model is EXPERIMENTAL and not viable for +# Cairn's agentic tool use — the model is too small / context too tight (fixed +# 4096 tokens). The USER-FACING Apple provider is Private Cloud Compute (below), +# NOT this. The on-device model is HIDDEN from end users. +# +# Set to 1 in your LOCAL .env.local to expose the on-device model in Settings → +# AI so you can keep iterating. Leave BLANK/unset for EAS and shipped builds. +# (The native module still compiles but stays dormant unless this flag is on.) EXPO_PUBLIC_APPLE_LLM_DEV= + +# ───────────────────────────────────────────────────────────────────────────── +# Apple Intelligence via Private Cloud Compute (iOS 27+) +# ───────────────────────────────────────────────────────────────────────────── +# PCC is the USER-FACING Apple provider: 32K context, stronger reasoning, no API +# key, privacy-preserving. It only compiles into the app when built against the +# iOS 27+ SDK (the AppleLlm podspec auto-defines CAIRN_PCC_SDK from the active +# SDK), and only runs on an eligible iOS 27+ device — so it's a no-op on the +# current EAS image (iOS 26 SDK) and simply reports unavailable. +# +# Set to 1 ONLY once the managed entitlement +# `com.apple.developer.private-cloud-compute` is granted for the App ID AND you +# build with the iOS 27 SDK (Xcode 27). This makes withPrivateCloudCompute inject +# the entitlement. Leave BLANK otherwise — adding an unprovisioned managed +# entitlement breaks code-signing on every build. +EXPO_PUBLIC_PCC= diff --git a/mobile/app.json b/mobile/app.json index 9b05c6354..08b4116ab 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -37,6 +37,9 @@ ], "./plugins/withAppleSigning", "./plugins/withICloudContainer", + "./plugins/withPrivateCloudCompute", + "./plugins/withSceneLifecycle", + "./plugins/withPodDeploymentTarget", "./plugins/withStatusBarStyle", "@react-native-community/datetimepicker", "expo-secure-store", @@ -52,6 +55,14 @@ "backgroundColor": "#0d0d0d" } } + ], + [ + "expo-build-properties", + { + "ios": { + "deploymentTarget": "16.4" + } + } ] ], "experiments": { diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 5d58dae86..1c426ab17 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -32,15 +32,17 @@ export default function TabsLayout() { Graph - - - Search - - Chat + + {/* Search last so it sits on the far right — matches the iOS search-tab + convention and keeps it out of the primary nav flow. */} + + + Search + ); diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 67048f7d5..438efdb72 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -13,14 +13,14 @@ import { import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { KeyboardStickyView, useKeyboardHandler } from "react-native-keyboard-controller"; import { Stack, useRouter, useFocusEffect } from "expo-router"; -import { CheckCircle, Bot, User, Send, ImagePlus, X, Settings2 } from "lucide-react-native"; +import { CheckCircle, Bot, User, Send, ImagePlus, X, Settings2, Brain, ChevronRight } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TabScreen } from "@/components/TabScreen"; import { EmptyState } from "@/components/EmptyState"; import { GlassBar, glassActive } from "@/components/GlassBar"; import { MarkdownView } from "@/components/MarkdownView"; import { ICON_DELETE, ICON_AI } from "@/components/toolbar-icons"; -import { useTheme, withAlpha, type as typeScale, type Theme } from "@/theme"; +import { useTheme, withAlpha, tabBarClosedLift, KEYBOARD_OPEN_GAP, type as typeScale, type Theme } from "@/theme"; import { runAgent, userMessage, assistantMessage, type AgentEvent, type Attachment } from "@/chat/agent"; import { haptics, toolbarPress } from "@/haptics"; import { pickImages, takePhoto } from "@/chat/attachments"; @@ -28,10 +28,11 @@ import { loadChatHistory, saveChatMessage, clearChatHistory } from "@/db/chat-st import { hasProvider } from "@/chat/providers"; import { resetAppleSession } from "@/chat/providers/apple"; import { prettifyToolLabel } from "@cairn/shared/ui/constants"; -import type { UIMessage } from "@/chat/providers/types"; +import type { UIMessage, ChatUsage } from "@/chat/providers/types"; +import { ContextRing } from "@/components/ContextRing"; +import { GlassMenu } from "@/components/GlassMenu"; +import { Button } from "@expo/ui/swift-ui"; -/** Standard UIKit tab bar content height (excludes the home-indicator inset). */ -const TAB_BAR_BASE = 49; /** Composer height assumed before its first onLayout measurement. */ const COMPOSER_FALLBACK_H = 60; @@ -40,6 +41,10 @@ interface UiMessage { content: string; images?: string[]; // data URIs for user attachments tools?: { tool: string; ok: boolean }[]; + /** Live reasoning ("thinking") text for turns whose model streams it (Apple + * PCC, or OpenAI-compatible endpoints exposing reasoning/reasoning_content). + * Shown collapsibly; like images it's session-only and not persisted. */ + reasoning?: string; streaming?: boolean; } @@ -80,6 +85,8 @@ export default function ChatScreen() { const [attachments, setAttachments] = useState([]); const [busy, setBusy] = useState(false); const [configured, setConfigured] = useState(true); + // Context-window usage for the ring (Apple provider reports it per turn). + const [usage, setUsage] = useState(null); const scrollRef = useRef(null); const router = useRouter(); // Persistent agent conversation (UIMessage parts format) across turns. Seeded @@ -115,24 +122,19 @@ export default function ChatScreen() { ); const { height: kbHeight } = useGradualAnimation(); - // Approximate the native (translucent) iOS tab bar height. NativeTabs doesn't - // expose BottomTabBarHeightContext, so we reconstruct it: the standard UIKit - // tab bar is 49pt tall and sits above the home-indicator safe area. - // - // Lift the composer just above the tab bar when the keyboard is closed. We - // lift by only slightly more than the bar's visible height (not the full - // safe-area-inclusive height) so the composer hugs the bar instead of - // floating well above it. - const closedLift = TAB_BAR_BASE + insets.bottom * 0.5; + // Lift the composer just above the tab bar when the keyboard is closed + // (shared with the search scope bar so both rest at the same height). + const closedLift = tabBarClosedLift(insets.bottom); // Height the composer occupies, measured lazily (falls back before layout). const [composerH, setComposerH] = useState(COMPOSER_FALLBACK_H); // Animated spacer at the BOTTOM of the scroll content. It always keeps the // last message clear of the floating composer, and grows to clear the // keyboard when it opens (so you can scroll the newest message above the // keyboard). When closed it matches where the composer actually rests - // (closedLift above the screen bottom) so there's no dead space. + // (closedLift above the screen bottom) so there's no dead space. The +GAP on + // the open case matches the composer's KEYBOARD_OPEN_GAP lift. const bottomSpacer = useAnimatedStyle(() => ({ - height: composerH + 12 + Math.max(kbHeight.value, closedLift), + height: composerH + 12 + Math.max(kbHeight.value + KEYBOARD_OPEN_GAP, closedLift), }), [composerH, closedLift]); const send = useCallback(async () => { @@ -155,6 +157,7 @@ export default function ChatScreen() { // Persist the user turn locally (on-device only — chat never syncs). saveChatMessage({ role: "user", content: text, images: atts.map((a) => a.url) }); let acc = ""; + let reasoningAcc = ""; const toolTrail: { tool: string; ok: boolean }[] = []; const patchAssistant = (patch: Partial) => { @@ -175,11 +178,19 @@ export default function ChatScreen() { if (e.type === "text-delta" && e.delta) { acc += e.delta; patchAssistant({ content: acc }); + } else if (e.type === "reasoning-delta" && e.delta) { + reasoningAcc += e.delta; + patchAssistant({ reasoning: reasoningAcc }); } else if (e.type === "tool" && e.tool) { const ok = !(e.result && typeof e.result === "object" && "error" in (e.result as object)); toolTrail.push({ tool: e.tool, ok }); patchAssistant({ tools: [...toolTrail] }); haptics.impact(); // agent ran a tool + } else if (e.type === "final" && e.usage) { + // Only drive the ring with valid token counts — a negative prompt + // count or non-positive limit renders a broken/empty ring. + const u = e.usage; + if (u.promptTokens >= 0 && u.contextLimit > 0) setUsage(u); } setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 20); }; @@ -243,6 +254,7 @@ export default function ChatScreen() { clearChatHistory(); conversation.current = []; setMessages([]); + setUsage(null); // Drop the on-device session so a new chat gets a fresh context window. resetAppleSession(); }, @@ -252,7 +264,23 @@ export default function ChatScreen() { return ( - + 0 + ? () => ( + + ) + : undefined, + }} + /> setComposerH(e.nativeEvent.layout.height)}> @@ -323,9 +352,16 @@ export default function ChatScreen() { - - - + } + accessibilityLabel="Add image" + disabled={busy} + onFallbackPress={onAttach} + triggerStyle={styles.attachBtn} + > +