diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..9f0820c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,116 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Stable version without the v prefix, for example 0.2.7
+ required: true
+ type: string
+ dry_run:
+ description: Build and validate without creating a GitHub Release
+ required: true
+ default: true
+ type: boolean
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Build signed update
+ runs-on: macos-15
+ timeout-minutes: 20
+ environment: release
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Validate version
+ run: Scripts/validate-release.sh '${{ inputs.version }}'
+
+ - name: Build universal app
+ env:
+ CODEX_LIMITS_UNIVERSAL: 1
+ run: Scripts/build-app.sh
+
+ - name: Prepare release notes and archive
+ env:
+ GH_TOKEN: '${{ github.token }}'
+ VERSION: '${{ inputs.version }}'
+ run: |
+ set -euo pipefail
+ artifacts="$RUNNER_TEMP/release-artifacts"
+ archive="Codex-Limits-$VERSION.zip"
+ mkdir -p "$artifacts"
+ previous_tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1)
+ gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
+ -f tag_name="v$VERSION" \
+ -f target_commitish="$GITHUB_SHA" \
+ -f previous_tag_name="$previous_tag" \
+ --jq .body > "$artifacts/Codex-Limits-$VERSION.md"
+ ditto -c -k --sequesterRsrc --keepParent \
+ '.build/release/Codex Limits.app' \
+ "$artifacts/$archive"
+ echo "ARTIFACTS=$artifacts" >> "$GITHUB_ENV"
+ echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
+
+ - name: Sign archive and feed
+ env:
+ SPARKLE_PRIVATE_KEY: '${{ secrets.SPARKLE_PRIVATE_KEY }}'
+ VERSION: '${{ inputs.version }}'
+ run: |
+ set -euo pipefail
+ test -n "$SPARKLE_PRIVATE_KEY"
+ printf '%s' "$SPARKLE_PRIVATE_KEY" | \
+ .build/universal-arm64/artifacts/sparkle/Sparkle/bin/generate_appcast \
+ --ed-key-file - \
+ --download-url-prefix \
+ "https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/" \
+ --embed-release-notes \
+ --maximum-deltas 0 \
+ --critical-update-version '' \
+ "$ARTIFACTS"
+
+ - name: Validate update artifacts
+ run: |
+ set -euo pipefail
+ app='.build/release/Codex Limits.app'
+ codesign --verify --deep --strict "$app"
+ archs=$(lipo -archs "$app/Contents/MacOS/CodexLimits")
+ test "$archs" = 'x86_64 arm64' -o "$archs" = 'arm64 x86_64'
+ xmllint --noout "$ARTIFACTS/appcast.xml"
+ grep -q 'sparkle:edSignature' "$ARTIFACTS/appcast.xml"
+ grep -q 'sparkle:criticalUpdate' "$ARTIFACTS/appcast.xml"
+
+ - name: Upload dry-run artifacts
+ if: inputs.dry_run
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: Codex-Limits-${{ inputs.version }}
+ path: |
+ ${{ env.ARTIFACTS }}/${{ env.ARCHIVE }}
+ ${{ env.ARTIFACTS }}/appcast.xml
+ ${{ env.ARTIFACTS }}/Codex-Limits-${{ inputs.version }}.md
+ if-no-files-found: error
+
+ - name: Create draft release
+ if: inputs.dry_run == false
+ env:
+ GH_TOKEN: '${{ github.token }}'
+ VERSION: '${{ inputs.version }}'
+ run: |
+ gh release create "v$VERSION" \
+ --draft \
+ --target "$GITHUB_SHA" \
+ --title "Codex Limits $VERSION" \
+ --notes-file "$ARTIFACTS/Codex-Limits-$VERSION.md" \
+ "$ARTIFACTS/$ARCHIVE" \
+ "$ARTIFACTS/appcast.xml"
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..ee38a95
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "67c9b3e2c57199671372138fd7fafb42a4af4ccf81e4f0197819ae0b3070893a",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "79bc9e872948e47877e76f194cb0c8e0412b0b90",
+ "version" : "2.9.5"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 4ff29cf..da46f5a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -8,8 +8,17 @@ let package = Package(
products: [
.executable(name: "CodexLimits", targets: ["CodexLimits"])
],
+ dependencies: [
+ .package(
+ url: "https://github.com/sparkle-project/Sparkle",
+ exact: "2.9.5"
+ )
+ ],
targets: [
- .executableTarget(name: "CodexLimits"),
+ .executableTarget(
+ name: "CodexLimits",
+ dependencies: ["Sparkle"]
+ ),
.testTarget(
name: "CodexLimitsTests",
dependencies: ["CodexLimits"],
diff --git a/README.md b/README.md
index d5f7613..1c46c8b 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ The app keeps weak estimates out of guidance and Insights. The Usage remaining c
- Copies account usage samples to a private folder that you choose.
- Deletes all Codex Limits analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`.
- Refreshes on launch, after wake, when you open the menu, every ten minutes, or on request.
-- Runs as a native SwiftUI menu-bar app with no third-party runtime dependencies.
+- Runs as a native SwiftUI menu-bar app and uses Sparkle to verify and install signed updates.
- Does not redeem resets, change Codex settings, or control Tasks.
## How it works
@@ -131,7 +131,7 @@ The script creates an ad-hoc signed app at `.build/release/Codex Limits.app`. La
open ".build/release/Codex Limits.app"
```
-This project offers no prebuilt or notarized app. Open `Package.swift` in Xcode to work on the source.
+Stable releases include a universal app for Apple Silicon and Intel. The app is not Developer ID signed or notarized, so the first manual installation remains subject to macOS Gatekeeper. After that, the app can detect and install EdDSA-signed stable updates. Open `Package.swift` in Xcode to work on the source.
## Test
@@ -143,7 +143,7 @@ The tests use made-up usage data. Do not commit exported account data or local a
## Current limitations
-- You must build the app from source.
+- Existing 0.2.6 and older installations require one final manual update to a version that includes the in-app updater.
- Account and local values can differ because this Mac may not observe every Codex Task.
- Estimates need account readings near both ends of a time range and enough similar local work.
- `Analyze with Codex` appears only when Codex offers GPT-5.6 Luna with Medium reasoning.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 4adf947..2d1f101 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.2.6
+ 0.2.7
CFBundleVersion
- 7
+ 8
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
@@ -28,5 +28,17 @@
NSPrincipalClass
NSApplication
+ SUAllowsAutomaticUpdates
+
+ SUEnableAutomaticChecks
+
+ SUFeedURL
+ https://github.com/thrr87/codex-limits/releases/latest/download/appcast.xml
+ SUPublicEDKey
+ 3HnMDZs+eAgmWfY3G8N0OgGKaYX1O+opzEYIaiYBR58=
+ SURequireSignedFeed
+
+ SUVerifyUpdateBeforeExtraction
+
diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh
index a089aec..c9e2a33 100755
--- a/Scripts/build-app.sh
+++ b/Scripts/build-app.sh
@@ -14,11 +14,60 @@ if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then
build_args+=(-Xswiftc -DCODEX_LIMITS_QA)
fi
-xcrun swift build "${build_args[@]}"
+if [[ ${CODEX_LIMITS_UNIVERSAL:-0} == 1 ]]; then
+ for architecture in arm64 x86_64; do
+ scratch="$project_dir/.build/universal-$architecture"
+ xcrun swift build "${build_args[@]}" \
+ --triple "$architecture-apple-macosx14.0" \
+ --scratch-path "$scratch" \
+ --cache-path "$project_dir/.build/package-cache"
+ done
+ arm_release="$project_dir/.build/universal-arm64/arm64-apple-macosx/release"
+ intel_release="$project_dir/.build/universal-x86_64/x86_64-apple-macosx/release"
+ executable="$project_dir/.build/release/CodexLimits"
+ mkdir -p "${executable:h}"
+ lipo -create \
+ "$arm_release/CodexLimits" \
+ "$intel_release/CodexLimits" \
+ -output "$executable"
+ framework="$arm_release/Sparkle.framework"
+else
+ xcrun swift build "${build_args[@]}"
+ executable="$project_dir/.build/release/CodexLimits"
+ framework="$project_dir/.build/release/Sparkle.framework"
+fi
+
rm -rf "$app_dir"
-mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources"
-cp .build/release/CodexLimits "$app_dir/Contents/MacOS/CodexLimits"
+mkdir -p \
+ "$app_dir/Contents/MacOS" \
+ "$app_dir/Contents/Resources" \
+ "$app_dir/Contents/Frameworks"
+cp "$executable" "$app_dir/Contents/MacOS/CodexLimits"
+install_name_tool -add_rpath \
+ @loader_path/../Frameworks \
+ "$app_dir/Contents/MacOS/CodexLimits"
+ditto "$framework" "$app_dir/Contents/Frameworks/Sparkle.framework"
cp Resources/Info.plist "$app_dir/Contents/Info.plist"
+if [[ -n ${CODEX_LIMITS_VERSION:-} ]]; then
+ /usr/libexec/PlistBuddy -c \
+ "Set :CFBundleShortVersionString $CODEX_LIMITS_VERSION" \
+ "$app_dir/Contents/Info.plist"
+fi
+if [[ -n ${CODEX_LIMITS_BUILD:-} ]]; then
+ /usr/libexec/PlistBuddy -c \
+ "Set :CFBundleVersion $CODEX_LIMITS_BUILD" \
+ "$app_dir/Contents/Info.plist"
+fi
+if [[ -n ${CODEX_LIMITS_FEED_URL:-} ]]; then
+ /usr/libexec/PlistBuddy -c \
+ "Set :SUFeedURL $CODEX_LIMITS_FEED_URL" \
+ "$app_dir/Contents/Info.plist"
+fi
+if [[ -n ${CODEX_LIMITS_PUBLIC_ED_KEY:-} ]]; then
+ /usr/libexec/PlistBuddy -c \
+ "Set :SUPublicEDKey $CODEX_LIMITS_PUBLIC_ED_KEY" \
+ "$app_dir/Contents/Info.plist"
+fi
if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then
/usr/libexec/PlistBuddy -c \
"Set :CFBundleIdentifier com.github.thrr87.CodexLimits.QA" \
@@ -26,7 +75,14 @@ if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then
/usr/libexec/PlistBuddy -c \
"Set :CFBundleDisplayName Codex Limits QA" \
"$app_dir/Contents/Info.plist"
+ /usr/libexec/PlistBuddy -c \
+ "Add :NSAppTransportSecurity dict" \
+ "$app_dir/Contents/Info.plist"
+ /usr/libexec/PlistBuddy -c \
+ "Add :NSAppTransportSecurity:NSAllowsLocalNetworking bool true" \
+ "$app_dir/Contents/Info.plist"
fi
-codesign --force --sign - "$app_dir"
+codesign --force --deep --sign - "$app_dir"
+codesign --verify --deep --strict "$app_dir"
print -r -- "$app_dir"
diff --git a/Scripts/qa-app.sh b/Scripts/qa-app.sh
index 8b36ea0..a2ff540 100755
--- a/Scripts/qa-app.sh
+++ b/Scripts/qa-app.sh
@@ -3,13 +3,19 @@ set -euo pipefail
project_dir=${0:A:h:h}
app_dir="$project_dir/.build/release/Codex Limits.app"
+update_dir="$project_dir/.build/qa-update"
executable_pattern="$project_dir/.build/.*/Codex Limits.app/Contents/MacOS/CodexLimits"
relative_executable_pattern="\\.build/.*/Codex Limits\\.app/Contents/MacOS/CodexLimits"
+update_executable_pattern="$update_dir/Codex Limits QA.app/Contents/MacOS/CodexLimits"
action=${1:-launch}
cleanup() {
pkill -f "$executable_pattern" 2>/dev/null || true
pkill -f "$relative_executable_pattern" 2>/dev/null || true
+ pkill -f "$update_executable_pattern" 2>/dev/null || true
+ if [[ -f "$update_dir/server.pid" ]]; then
+ kill "$(<"$update_dir/server.pid")" 2>/dev/null || true
+ fi
}
case "$action" in
@@ -21,8 +27,58 @@ case "$action" in
CODEX_LIMITS_QA=1 "$project_dir/Scripts/build-app.sh"
open "$app_dir"
;;
+ update)
+ cleanup
+ rm -rf "$update_dir"
+ mkdir -p "$update_dir/feed"
+
+ key_material=$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
+ xcrun swift -e 'import CryptoKit; import Foundation; let key = Curve25519.Signing.PrivateKey(); print(key.rawRepresentation.base64EncodedString(), key.publicKey.rawRepresentation.base64EncodedString())')
+ private_key=${key_material%% *}
+ public_key=${key_material#* }
+
+ feed_url="http://127.0.0.1:8765/appcast.xml"
+ CODEX_LIMITS_QA=1 \
+ CODEX_LIMITS_VERSION=0.2.6 \
+ CODEX_LIMITS_BUILD=7 \
+ CODEX_LIMITS_FEED_URL="$feed_url" \
+ CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \
+ "$project_dir/Scripts/build-app.sh"
+ ditto "$app_dir" "$update_dir/Codex Limits QA.app"
+
+ CODEX_LIMITS_QA=1 \
+ CODEX_LIMITS_VERSION=0.2.7 \
+ CODEX_LIMITS_BUILD=8 \
+ CODEX_LIMITS_FEED_URL="$feed_url" \
+ CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \
+ "$project_dir/Scripts/build-app.sh"
+ archive="$update_dir/feed/Codex-Limits-QA-0.2.7.zip"
+ ditto -c -k --sequesterRsrc --keepParent "$app_dir" "$archive"
+ print -r -- 'Secure in-app updates are ready for local QA.' \
+ > "$update_dir/feed/Codex-Limits-QA-0.2.7.md"
+ print -rn -- "$private_key" | \
+ "$project_dir/.build/artifacts/sparkle/Sparkle/bin/generate_appcast" \
+ --ed-key-file - \
+ --download-url-prefix "http://127.0.0.1:8765/" \
+ --embed-release-notes \
+ --maximum-deltas 0 \
+ --critical-update-version '' \
+ "$update_dir/feed"
+
+ nohup python3 -m http.server 8765 --bind 127.0.0.1 \
+ --directory "$update_dir/feed" \
+ "$update_dir/server.log" 2>&1 &
+ print -r -- $! > "$update_dir/server.pid"
+ for _ in {1..20}; do
+ curl --silent --fail "$feed_url" >/dev/null && break
+ sleep 0.1
+ done
+ curl --silent --fail "$feed_url" >/dev/null
+ open "$update_dir/Codex Limits QA.app"
+ wait "$(<"$update_dir/server.pid")"
+ ;;
*)
- print -u2 "Usage: $0 [launch|cleanup]"
+ print -u2 "Usage: $0 [launch|update|cleanup]"
exit 64
;;
esac
diff --git a/Scripts/validate-release.sh b/Scripts/validate-release.sh
new file mode 100755
index 0000000..2111c9b
--- /dev/null
+++ b/Scripts/validate-release.sh
@@ -0,0 +1,64 @@
+#!/bin/zsh
+set -euo pipefail
+
+autoload -Uz is-at-least
+
+is_newer_than() {
+ local candidate=$1
+ local previous=$2
+ ! is-at-least "$candidate" "$previous"
+}
+
+if [[ ${1:-} == --self-test ]]; then
+ is_newer_than 0.2.7 0.2.6
+ ! is_newer_than 0.2.7 0.2.7
+ ! is_newer_than 0.2.7 0.2.8
+ print "Release version checks passed"
+ exit
+fi
+
+version=${1:?"Usage: $0 VERSION"}
+[[ $version =~ '^[0-9]+\.[0-9]+\.[0-9]+$' ]] || {
+ print -u2 "Release version must be stable semantic versioning, for example 0.2.7"
+ exit 64
+}
+
+project_dir=${0:A:h:h}
+plist="$project_dir/Resources/Info.plist"
+plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist")
+build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist")
+[[ $plist_version == $version ]] || {
+ print -u2 "Info.plist version is $plist_version, expected $version"
+ exit 65
+}
+[[ $build == <-> ]] || {
+ print -u2 "CFBundleVersion must be an integer"
+ exit 65
+}
+
+cd "$project_dir"
+tag="v$version"
+if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then
+ print -u2 "$tag already exists"
+ exit 65
+fi
+
+latest_tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1)
+if [[ -n $latest_tag ]]; then
+ latest_version=${latest_tag#v}
+ is_newer_than "$version" "$latest_version" || {
+ print -u2 "$version must be newer than $latest_version"
+ exit 65
+ }
+
+ previous_plist=$(mktemp)
+ trap 'rm -f "$previous_plist"' EXIT
+ git show "$latest_tag:Resources/Info.plist" > "$previous_plist"
+ previous_build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$previous_plist")
+ (( build > previous_build )) || {
+ print -u2 "Build $build must be greater than $previous_build"
+ exit 65
+ }
+fi
+
+print "Validated $tag (build $build)"
diff --git a/Sources/CodexLimits/AppUpdater.swift b/Sources/CodexLimits/AppUpdater.swift
new file mode 100644
index 0000000..e0dcc65
--- /dev/null
+++ b/Sources/CodexLimits/AppUpdater.swift
@@ -0,0 +1,57 @@
+import Sparkle
+
+@MainActor
+final class AppUpdater: NSObject, ObservableObject {
+ @Published private(set) var availableVersion: String?
+
+ private var started = false
+ private lazy var controller = SPUStandardUpdaterController(
+ startingUpdater: false,
+ updaterDelegate: self,
+ userDriverDelegate: self
+ )
+
+ func start() {
+ guard !started else { return }
+ started = true
+
+ controller.startUpdater()
+ controller.updater.updateCheckInterval = 6 * 60 * 60
+ controller.updater.checkForUpdatesInBackground()
+ }
+
+ func showAvailableUpdate() {
+ controller.checkForUpdates(nil)
+ }
+}
+
+@MainActor
+extension AppUpdater: SPUUpdaterDelegate {
+ func updater(
+ _ updater: SPUUpdater,
+ didFindValidUpdate item: SUAppcastItem
+ ) {
+ availableVersion = item.displayVersionString
+ }
+
+ func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
+ availableVersion = nil
+ }
+}
+
+extension AppUpdater: SPUStandardUserDriverDelegate {
+ nonisolated var supportsGentleScheduledUpdateReminders: Bool { true }
+
+ nonisolated func standardUserDriverShouldHandleShowingScheduledUpdate(
+ _ update: SUAppcastItem,
+ andInImmediateFocus immediateFocus: Bool
+ ) -> Bool {
+ false
+ }
+
+ nonisolated func standardUserDriverWillHandleShowingUpdate(
+ _ handleShowingUpdate: Bool,
+ forUpdate update: SUAppcastItem,
+ state: SPUUserUpdateState
+ ) {}
+}
diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift
index 0b7c4a6..c54357d 100644
--- a/Sources/CodexLimits/CodexAssistedInsights.swift
+++ b/Sources/CodexLimits/CodexAssistedInsights.swift
@@ -61,6 +61,7 @@ enum CodexAssistedModelCatalog {
enum CodexMetadataEvidenceField: String, Codable, CaseIterable, Sendable {
case usageRemaining = "usage_remaining"
+ case paceGuidance = "pace_guidance"
case accountTokenActivity = "account_token_activity"
case localTokenActivity = "local_token_activity"
case activity
@@ -68,23 +69,60 @@ enum CodexMetadataEvidenceField: String, Codable, CaseIterable, Sendable {
case activeTimeAvailable = "active_time_available"
}
-enum CodexAssistedInsightKind: String, Codable, CaseIterable, Sendable {
- case usageRemaining = "usage_remaining_status"
- case accountTokenActivity = "account_token_activity"
- case localTokenActivity = "local_token_activity"
- case activity = "activity_summary"
- case usagePerToken = "usage_per_token_change"
- case activeTimeAvailable = "active_time_available"
+enum CodexAssistedResponseStatus: String, Codable, Sendable {
+ case insight
+ case insufficientEvidence = "insufficient_evidence"
+}
- var evidenceField: CodexMetadataEvidenceField {
- switch self {
- case .usageRemaining: .usageRemaining
- case .accountTokenActivity: .accountTokenActivity
- case .localTokenActivity: .localTokenActivity
- case .activity: .activity
- case .usagePerToken: .usagePerToken
- case .activeTimeAvailable: .activeTimeAvailable
- }
+struct CodexAssistedNarrative: Codable, Equatable, Sendable {
+ let status: CodexAssistedResponseStatus
+ let title: String
+ let finding: String
+ let whyItMatters: String
+ let recommendation: String
+
+ var isValid: Bool {
+ switch status {
+ case .insight:
+ return Self.isText(title, maximumLength: 80)
+ && Self.isText(finding, maximumLength: 360)
+ && Self.isText(whyItMatters, maximumLength: 360)
+ && Self.isText(recommendation, maximumLength: 360)
+ && !containsUnsupportedClaim
+ case .insufficientEvidence:
+ return title == "Not enough evidence"
+ && Self.isText(finding, maximumLength: 360)
+ && whyItMatters.isEmpty
+ && recommendation.isEmpty
+ }
+ }
+
+ private var containsUnsupportedClaim: Bool {
+ let words = Set(
+ [title, finding, whyItMatters, recommendation]
+ .joined(separator: " ")
+ .lowercased()
+ .split { !$0.isLetter }
+ .map(String.init)
+ )
+ return !words.isDisjoint(with: [
+ "because", "billing", "caused", "causes", "cost",
+ "efficiency", "efficient", "price", "pricing", "proves",
+ "quality", "waste", "will"
+ ])
+ }
+
+ private static func isText(
+ _ text: String,
+ maximumLength: Int
+ ) -> Bool {
+ !text.isEmpty
+ && text.count <= maximumLength
+ && text == text.trimmingCharacters(in: .whitespacesAndNewlines)
+ && text.unicodeScalars.allSatisfy {
+ !CharacterSet.controlCharacters.contains($0)
+ && !CharacterSet.decimalDigits.contains($0)
+ }
}
}
@@ -140,6 +178,18 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable {
let observedInterval: EpochRange?
}
+ struct PaceGuidance: Codable, Equatable, Sendable {
+ let status: String
+ let expectedRemainingAtReset: Double
+ let safetyRemainingAtReset: Double
+ let recommendedPercentPerDay: Double
+ let currentPercentPerDay: Double
+ let historicalPercentPerDay: Double?
+ let historicalReferenceSource: String?
+ let coverage: String
+ let confidence: String
+ }
+
struct AppliedFilters: Codable, Equatable, Sendable {
let project: Bool
let taskTree: Bool
@@ -159,6 +209,7 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable {
let usageRemaining: UsageRemaining
let weeklyResetAt: Int64?
let evidence: Evidence
+ let paceGuidance: PaceGuidance?
let accountTokenActivity: AccountTokens
let localTokenActivity: LocalTokens
let activity: Activity
@@ -240,6 +291,33 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable {
coverage: reader.evidence.coverage.rawValue,
confidence: reader.evidence.confidence.rawValue
),
+ paceGuidance: reader.guidance.flatMap { guidance in
+ let forecast = guidance.forecast
+ let values = [
+ forecast.expectedRemainingAtReset,
+ forecast.safetyRemainingAtReset,
+ forecast.recommendedPercentPerDay,
+ forecast.currentPercentPerDay
+ ]
+ guard values.allSatisfy(\.isFinite),
+ forecast.historicalReference?.percentPerDay.isFinite
+ ?? true else {
+ return nil
+ }
+ return PaceGuidance(
+ status: forecast.status.rawValue,
+ expectedRemainingAtReset: forecast.expectedRemainingAtReset,
+ safetyRemainingAtReset: forecast.safetyRemainingAtReset,
+ recommendedPercentPerDay: forecast.recommendedPercentPerDay,
+ currentPercentPerDay: forecast.currentPercentPerDay,
+ historicalPercentPerDay: forecast
+ .historicalReference?.percentPerDay,
+ historicalReferenceSource: forecast
+ .historicalReferenceSource?.rawValue,
+ coverage: reader.evidence.coverage.rawValue,
+ confidence: reader.evidence.confidence.rawValue
+ )
+ },
accountTokenActivity: AccountTokens(
tokens: reader.accountTokenActivity.tokens,
state: reader.accountTokenActivity.state.rawValue,
@@ -330,57 +408,6 @@ enum CodexAssistedRequestError: Error {
enum CodexAssistedRequestFactory {
static let maximumMetadataBytes = 8_192
static let maximumSourceBytes = 65_536
- static let toolProvidingFeatures: Set = [
- "apps",
- "artifact",
- "auth_elicitation",
- "browser_use",
- "browser_use_external",
- "browser_use_full_cdp_access",
- "code_mode",
- "code_mode_buffered_exec",
- "code_mode_host",
- "code_mode_only",
- "computer_use",
- "default_mode_request_user_input",
- "deferred_executor",
- "enable_mcp_apps",
- "executor_capability_discovery",
- "goals",
- "guardian_approval",
- "hooks",
- "image_generation",
- "in_app_browser",
- "multi_agent",
- "multi_agent_v2",
- "plugins",
- "plugin_sharing",
- "remote_plugin",
- "request_permissions_tool",
- "shell_snapshot",
- "shell_tool",
- "skill_mcp_dependency_install",
- "skill_search",
- "standalone_web_search",
- "tool_call_mcp_elicitation",
- "tool_suggest",
- "unified_exec",
- "workspace_dependencies"
- ]
- static let benignEnabledFeatures: Set = [
- "collaboration_modes",
- "enable_request_compression",
- "fast_mode",
- "mentions_v2",
- "personality",
- "remote_compaction_v2",
- "resize_all_images",
- "sqlite",
- "steer",
- "terminal_resize_reflow",
- "tool_search_always_defer_mcp_tools",
- "tui_app_server"
- ]
static func modelList(id: Int, cursor: String? = nil) -> [String: Any] {
return [
@@ -586,8 +613,9 @@ enum CodexAssistedRequestFactory {
private static let baseInstructions =
"Analyze only the JSON supplied in the user message. Do not use tools, files, commands, network access, other tasks, or outside knowledge."
- private static let developerInstructions =
- "Return only the current output schema. For metadata, choose the single most useful supported insight kind. For Source Content, choose only a High-confidence pattern supported by at least two exact category and one-based item references. Never quote or reproduce Source Content. If the evidence does not meet that bar, return an error."
+ private static let developerInstructions = """
+ Return only the current output schema. For Metadata analysis, the app preflights metadata for usefulness. When pace_guidance and usage_remaining are available, return insight with exactly those two evidence fields; explain what the current pace implies, why that matters before reset, and one concrete conditional adjustment, calibrated to their confidence even when coverage is Low or Partial. Otherwise, produce a Metadata insight only when at least two available High- or Complete-coverage evidence fields support a useful relationship. Keep title, finding, whyItMatters, and recommendation free of digits and the words because, billing, caused, causes, cost, efficiency, efficient, price, pricing, proves, quality, waste, will. Do not merely restate visible values or dates in prose, invent facts, claim causality, judge task quality or efficiency, or make billing, pricing, cost, or exact per-task allowance claims. If Metadata evidence cannot support a useful conclusion, return insufficient_evidence with title \"Not enough evidence\", explain what evidence is missing in finding, and leave the other prose and references empty. For Source Content analysis, require a High-confidence pattern supported by at least two exact category and one-based item references. Never quote or reproduce Source Content.
+ """
private static func requestText(metadata: String) -> String {
"""
@@ -600,11 +628,30 @@ enum CodexAssistedRequestFactory {
private static let metadataOutputSchema: [String: Any] = [
"type": "object",
"additionalProperties": false,
- "required": ["insightKind"],
+ "required": [
+ "status", "title", "finding", "whyItMatters",
+ "recommendation", "evidenceFields"
+ ],
"properties": [
- "insightKind": [
+ "status": [
"type": "string",
- "enum": CodexAssistedInsightKind.allCases.map(\.rawValue)
+ "enum": [
+ CodexAssistedResponseStatus.insight.rawValue,
+ CodexAssistedResponseStatus.insufficientEvidence.rawValue
+ ]
+ ],
+ "title": ["type": "string", "maxLength": 80],
+ "finding": ["type": "string", "maxLength": 360],
+ "whyItMatters": ["type": "string", "maxLength": 360],
+ "recommendation": ["type": "string", "maxLength": 360],
+ "evidenceFields": [
+ "type": "array",
+ "maxItems": 4,
+ "items": [
+ "type": "string",
+ "enum": CodexMetadataEvidenceField.allCases
+ .map(\.rawValue)
+ ]
]
]
]
@@ -612,15 +659,24 @@ enum CodexAssistedRequestFactory {
private static let sourceOutputSchema: [String: Any] = [
"type": "object",
"additionalProperties": false,
- "required": ["sourceInsightKind", "evidence"],
+ "required": [
+ "status", "title", "finding", "whyItMatters",
+ "recommendation", "evidence"
+ ],
"properties": [
- "sourceInsightKind": [
+ "status": [
"type": "string",
- "enum": CodexSourceInsightKind.allCases.map(\.rawValue)
+ "enum": [
+ CodexAssistedResponseStatus.insight.rawValue,
+ CodexAssistedResponseStatus.insufficientEvidence.rawValue
+ ]
],
+ "title": ["type": "string", "maxLength": 80],
+ "finding": ["type": "string", "maxLength": 360],
+ "whyItMatters": ["type": "string", "maxLength": 360],
+ "recommendation": ["type": "string", "maxLength": 360],
"evidence": [
"type": "array",
- "minItems": 2,
"maxItems": 6,
"items": [
"type": "object",
@@ -636,7 +692,6 @@ enum CodexAssistedRequestFactory {
"type": "array",
"minItems": 1,
"maxItems": 4,
- "uniqueItems": true,
"items": [
"type": "integer",
"minimum": 1
@@ -717,8 +772,11 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable {
var fingerprint: String {
let identity = Identity(
+ analysisContractVersion: 2,
accountPartitionID: accountPartitionID,
- payloadFingerprint: payload.fingerprint,
+ currentWindowResetAt: timeRange == .currentWindow
+ ? payload.weeklyResetAt
+ : nil,
sourceSelectionFingerprint: sourceSelectionFingerprint,
sourceCategories: sourceCategories,
timeRange: timeRange,
@@ -780,8 +838,9 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable {
}
private struct Identity: Codable {
+ let analysisContractVersion: Int
let accountPartitionID: String?
- let payloadFingerprint: String
+ let currentWindowResetAt: Int64?
let sourceSelectionFingerprint: String?
let sourceCategories: [String]?
let timeRange: AnalyticsTimeRange
@@ -792,26 +851,52 @@ struct CodexAssistedAnalysisScope: Equatable, Sendable {
}
}
-private struct DecodedCodexAssistedResult: Decodable {
- let insightKind: CodexAssistedInsightKind
+struct CodexMetadataInsightSelection: Decodable, Equatable, Sendable {
+ let status: CodexAssistedResponseStatus
+ let title: String
+ let finding: String
+ let whyItMatters: String
+ let recommendation: String
+ let evidenceFields: [CodexMetadataEvidenceField]
+
+ var narrative: CodexAssistedNarrative {
+ CodexAssistedNarrative(
+ status: status,
+ title: title,
+ finding: finding,
+ whyItMatters: whyItMatters,
+ recommendation: recommendation
+ )
+ }
}
enum CodexAssistedResultDecoder {
static func decode(
_ text: String
- ) throws -> CodexAssistedInsightKind {
+ ) throws -> CodexMetadataInsightSelection {
let data = Data(text.utf8)
guard let object = try JSONSerialization.jsonObject(
with: data
) as? [String: Any],
- Set(object.keys) == ["insightKind"] else {
+ Set(object.keys) == [
+ "status", "title", "finding", "whyItMatters",
+ "recommendation", "evidenceFields"
+ ] else {
throw CodexAssistedRequestError.invalidResult
}
let decoded = try JSONDecoder().decode(
- DecodedCodexAssistedResult.self,
+ CodexMetadataInsightSelection.self,
from: data
)
- return decoded.insightKind
+ guard decoded.narrative.isValid,
+ decoded.status == .insufficientEvidence
+ ? decoded.evidenceFields.isEmpty
+ : (2 ... 4).contains(decoded.evidenceFields.count),
+ Set(decoded.evidenceFields).count
+ == decoded.evidenceFields.count else {
+ throw CodexAssistedRequestError.invalidResult
+ }
+ return decoded
}
}
@@ -827,33 +912,89 @@ struct CodexAssistedEvidenceEnvelope: Equatable, Sendable {
enum CodexAssistedEvidenceResolver {
private struct Item {
- let title: String
- let summary: String
let evidence: String
let intervals: [DateInterval]
let coverage: CoverageLevel
}
+ static func canAnalyze(
+ payload: CodexMetadataAnalysisPayload
+ ) -> Bool {
+ guard UsageFreshness(rawValue: payload.evidence.freshness) == .fresh else {
+ return false
+ }
+ let available = Dictionary(
+ uniqueKeysWithValues: CodexMetadataEvidenceField.allCases.compactMap {
+ field in item(for: field, payload: payload).map { (field, $0) }
+ }
+ )
+ if available[.paceGuidance] != nil,
+ available[.usageRemaining] != nil {
+ return true
+ }
+ return available.values.filter {
+ $0.coverage == .complete || $0.coverage == .high
+ }.count >= 2
+ }
+
static func resolve(
- kind: CodexAssistedInsightKind,
+ selection: CodexMetadataInsightSelection,
payload: CodexMetadataAnalysisPayload
) -> CodexAssistedEvidenceEnvelope? {
+ if selection.status == .insufficientEvidence {
+ return CodexAssistedEvidenceEnvelope(
+ title: selection.title,
+ summary: selection.finding,
+ evidence: [],
+ intervals: [],
+ freshness: UsageFreshness(
+ rawValue: payload.evidence.freshness
+ ) ?? .unavailable,
+ coverage: .unavailable,
+ confidence: .unavailable
+ )
+ }
guard UsageFreshness(rawValue: payload.evidence.freshness) == .fresh,
- let item = item(
- for: kind.evidenceField,
- payload: payload
- ),
- item.coverage == .complete || item.coverage == .high else {
+ selection.narrative.isValid else {
return nil
}
+ let items = selection.evidenceFields.compactMap {
+ item(for: $0, payload: payload)
+ }
+ let usesPaceGuidance = selection.evidenceFields.contains(.paceGuidance)
+ && selection.evidenceFields.contains(.usageRemaining)
+ guard items.count == selection.evidenceFields.count,
+ usesPaceGuidance || items.allSatisfy({
+ $0.coverage == .complete || $0.coverage == .high
+ }) else {
+ return nil
+ }
+ let intervals = items.flatMap(\.intervals).reduce(
+ into: [DateInterval]()
+ ) {
+ if !$0.contains($1) { $0.append($1) }
+ }
return CodexAssistedEvidenceEnvelope(
- title: item.title,
- summary: item.summary,
- evidence: [item.evidence],
- intervals: item.intervals,
+ title: selection.title,
+ summary: selection.finding,
+ evidence: [
+ "Why it matters: \(selection.whyItMatters)",
+ "Try next: \(selection.recommendation)"
+ ] + items.map(\.evidence),
+ intervals: intervals,
freshness: .fresh,
- coverage: item.coverage,
- confidence: .high
+ coverage: usesPaceGuidance
+ ? CoverageLevel(
+ rawValue: payload.paceGuidance?.coverage ?? ""
+ ) ?? .unavailable
+ : items.allSatisfy({ $0.coverage == .complete })
+ ? .complete
+ : .high,
+ confidence: usesPaceGuidance
+ ? ConfidenceLevel(
+ rawValue: payload.paceGuidance?.confidence ?? ""
+ ) ?? .unavailable
+ : .high
)
}
@@ -870,12 +1011,39 @@ enum CodexAssistedEvidenceResolver {
return nil
}
return Item(
- title: "Usage remaining",
- summary: "You have \(number(percent))% usage remaining in this weekly window.",
evidence: "Usage remaining: \(number(percent))%.",
intervals: [interval],
coverage: .complete
)
+ case .paceGuidance:
+ guard let guidance = payload.paceGuidance,
+ let status = PaceStatus(rawValue: guidance.status),
+ let coverage = CoverageLevel(rawValue: guidance.coverage),
+ let confidence = ConfidenceLevel(
+ rawValue: guidance.confidence
+ ),
+ coverage != .unavailable,
+ coverage != .notApplicable,
+ confidence != .unavailable,
+ let interval = dateInterval(payload.usageRemaining.interval),
+ [
+ guidance.expectedRemainingAtReset,
+ guidance.safetyRemainingAtReset,
+ guidance.recommendedPercentPerDay,
+ guidance.currentPercentPerDay
+ ].allSatisfy({ $0.isFinite && $0 >= 0 }) else {
+ return nil
+ }
+ guard guidance.historicalPercentPerDay.map({
+ $0.isFinite && $0 >= 0
+ }) ?? true else {
+ return nil
+ }
+ return Item(
+ evidence: "Pace guidance: \(paceTitle(status)); current \(number(guidance.currentPercentPerDay))% per day, recommended up to \(number(guidance.recommendedPercentPerDay))% per day, expected \(number(guidance.expectedRemainingAtReset))% left at reset.",
+ intervals: [interval],
+ coverage: coverage
+ )
case .accountTokenActivity:
guard payload.accountTokenActivity.state == "exact",
let tokens = payload.accountTokenActivity.tokens,
@@ -885,8 +1053,6 @@ enum CodexAssistedEvidenceResolver {
return nil
}
return Item(
- title: "Account Token Activity",
- summary: "Account Token Activity is \(tokens.formatted()) tokens for this period.",
evidence: "Account Token Activity: \(tokens.formatted()) tokens.",
intervals: [interval],
coverage: .complete
@@ -902,8 +1068,6 @@ enum CodexAssistedEvidenceResolver {
return nil
}
return Item(
- title: "Local Token Activity",
- summary: "Local Token Activity is \(tokens.formatted()) tokens for this period.",
evidence: "Local Token Activity: \(tokens.formatted()) tokens.",
intervals: [interval],
coverage: coverage
@@ -919,12 +1083,7 @@ enum CodexAssistedEvidenceResolver {
let peak = payload.activity.peakConcurrentTasks.map {
" Peak concurrent Tasks: \($0)."
} ?? ""
- let peakSummary = payload.activity.peakConcurrentTasks.map {
- " Peak concurrent Tasks reached \($0)."
- } ?? ""
return Item(
- title: "Activity",
- summary: "Active time is \(Int(activeSeconds.rounded()).formatted()) seconds for this period.\(peakSummary)",
evidence: "Active time: \(Int(activeSeconds.rounded()).formatted()) seconds.\(peak)",
intervals: [interval],
coverage: coverage
@@ -945,8 +1104,6 @@ enum CodexAssistedEvidenceResolver {
return nil
}
return Item(
- title: "Usage per token",
- summary: "Usage per token is \(number(multiplier))× the reference for this period.",
evidence: "Usage per token: \(number(multiplier))× the reference.",
intervals: [current, reference],
coverage: coverage
@@ -965,8 +1122,6 @@ enum CodexAssistedEvidenceResolver {
return nil
}
return Item(
- title: "Estimated active time available",
- summary: "Estimated active time available is \(Int(lower.rounded()).formatted())–\(Int(upper.rounded()).formatted()) seconds.",
evidence: "Estimated active time available: \(Int(lower.rounded()).formatted())–\(Int(upper.rounded()).formatted()) seconds.",
intervals: [interval],
coverage: coverage
@@ -974,6 +1129,14 @@ enum CodexAssistedEvidenceResolver {
}
}
+ private static func paceTitle(_ status: PaceStatus) -> String {
+ switch status {
+ case .slowDown: "above the sustainable pace"
+ case .onTrack: "on track"
+ case .roomToUseMore: "below the available pace"
+ }
+ }
+
private static func dateInterval(
_ range: CodexMetadataAnalysisPayload.EpochRange?
) -> DateInterval? {
@@ -1185,7 +1348,7 @@ final class CodexAssistedInsightStore: ObservableObject {
func result(
for scope: CodexAssistedAnalysisScope
) -> CodexAssistedAnalysisResult? {
- if resultScope == scope, let result {
+ if resultScope?.fingerprint == scope.fingerprint, let result {
return result
}
return persistedResults.last {
@@ -1200,7 +1363,7 @@ final class CodexAssistedInsightStore: ObservableObject {
let selectionFingerprint = sourceSelection?.fingerprint
var candidates: [CodexAssistedAnalysisResult] = []
if let result,
- resultScope == scope
+ resultScope?.fingerprint == scope.fingerprint
|| (
selectionFingerprint != nil
&& resultScope?.sourceSelectionFingerprint
@@ -1277,7 +1440,8 @@ final class CodexAssistedInsightStore: ObservableObject {
) {
guard analysisTask == nil,
let profile,
- showsAnalyzeAction else {
+ showsAnalyzeAction,
+ CodexAssistedEvidenceResolver.canAnalyze(payload: payload) else {
return
}
lastSourceRequest = nil
@@ -1832,7 +1996,7 @@ actor CodexAssistedClient: CodexAssistedInsightServicing {
responseText
)
guard let resolved = CodexAssistedEvidenceResolver.resolve(
- kind: decoded,
+ selection: decoded,
payload: evidencePayload
) else {
throw CodexAssistedRequestError.invalidResult
@@ -1985,17 +2149,10 @@ actor CodexAssistedClient: CodexAssistedInsightServicing {
}
for item in page {
guard let name = item["name"] as? String,
- let enabled = item["enabled"] as? Bool else {
+ item["enabled"] is Bool else {
throw CodexAssistedClientError.invalidResponse
}
- if CodexAssistedRequestFactory.toolProvidingFeatures
- .contains(name) {
- featureNames.append(name)
- } else if enabled,
- !CodexAssistedRequestFactory
- .benignEnabledFeatures.contains(name) {
- throw CodexAssistedClientError.toolUseBlocked
- }
+ featureNames.append(name)
}
cursor = result["nextCursor"] as? String
if cursor == nil { break }
diff --git a/Sources/CodexLimits/CodexSourceContent.swift b/Sources/CodexLimits/CodexSourceContent.swift
index b6fe10d..9244df2 100644
--- a/Sources/CodexLimits/CodexSourceContent.swift
+++ b/Sources/CodexLimits/CodexSourceContent.swift
@@ -194,47 +194,28 @@ struct CodexSourceAnalysisPayload: Codable, Equatable, Sendable {
}
}
-enum CodexSourceInsightKind: String, Codable, CaseIterable, Sendable {
- case repeatedWork = "repeated_work"
- case verificationGap = "verification_gap"
- case repeatedToolSteps = "repeated_tool_steps"
- case longExchanges = "long_exchanges"
- case checksAfterChanges = "checks_after_changes"
-
- var title: String {
- switch self {
- case .repeatedWork: "Repeated work"
- case .verificationGap: "Verification gap"
- case .repeatedToolSteps: "Repeated tool steps"
- case .longExchanges: "Long exchanges"
- case .checksAfterChanges: "Checks followed changes"
- }
- }
-
- var summary: String {
- switch self {
- case .repeatedWork:
- "Codex found the same work in more than one selected item."
- case .verificationGap:
- "Codex found changes or completion claims without matching checks in the selected items."
- case .repeatedToolSteps:
- "Codex found tool or command steps that needed more than one attempt."
- case .longExchanges:
- "Codex found long or repeated exchanges that may work better as a smaller task."
- case .checksAfterChanges:
- "Codex found checks that followed changes in the selected items."
- }
- }
-}
-
struct CodexSourceEvidenceReference: Codable, Equatable, Sendable {
let category: CodexSourceContentCategory
let itemNumbers: [Int]
}
struct CodexSourceInsightSelection: Codable, Equatable, Sendable {
- let sourceInsightKind: CodexSourceInsightKind
+ let status: CodexAssistedResponseStatus
+ let title: String
+ let finding: String
+ let whyItMatters: String
+ let recommendation: String
let evidence: [CodexSourceEvidenceReference]
+
+ var narrative: CodexAssistedNarrative {
+ CodexAssistedNarrative(
+ status: status,
+ title: title,
+ finding: finding,
+ whyItMatters: whyItMatters,
+ recommendation: recommendation
+ )
+ }
}
enum CodexSourceResultDecoder {
@@ -247,13 +228,39 @@ enum CodexSourceResultDecoder {
guard let object = try JSONSerialization.jsonObject(
with: data
) as? [String: Any],
- Set(object.keys) == ["sourceInsightKind", "evidence"] else {
+ Set(object.keys) == [
+ "status", "title", "finding", "whyItMatters",
+ "recommendation", "evidence"
+ ] else {
throw CodexAssistedRequestError.invalidResult
}
let decoded = try JSONDecoder().decode(
CodexSourceInsightSelection.self,
from: data
)
+ guard decoded.narrative.isValid,
+ !exposesSourceContent(
+ decoded.narrative,
+ payload: payload
+ ) else {
+ throw CodexAssistedRequestError.invalidResult
+ }
+ if decoded.status == .insufficientEvidence {
+ guard decoded.evidence.isEmpty else {
+ throw CodexAssistedRequestError.invalidResult
+ }
+ return CodexAssistedEvidenceEnvelope(
+ title: decoded.title,
+ summary: decoded.finding,
+ evidence: [],
+ intervals: [],
+ freshness: UsageFreshness(
+ rawValue: metadata.evidence.freshness
+ ) ?? .unavailable,
+ coverage: .unavailable,
+ confidence: .unavailable
+ )
+ }
guard decoded.evidence.count >= 2,
decoded.evidence.count <= 6 else {
throw CodexAssistedRequestError.invalidResult
@@ -301,9 +308,12 @@ enum CodexSourceResultDecoder {
throw CodexAssistedRequestError.invalidResult
}
return CodexAssistedEvidenceEnvelope(
- title: decoded.sourceInsightKind.title,
- summary: decoded.sourceInsightKind.summary,
- evidence: labels,
+ title: decoded.title,
+ summary: decoded.finding,
+ evidence: [
+ "Why it matters: \(decoded.whyItMatters)",
+ "Try next: \(decoded.recommendation)"
+ ] + labels,
intervals: [interval],
freshness: UsageFreshness(
rawValue: metadata.evidence.freshness
@@ -312,6 +322,47 @@ enum CodexSourceResultDecoder {
confidence: .high
)
}
+
+ private static func exposesSourceContent(
+ _ narrative: CodexAssistedNarrative,
+ payload: CodexSourceAnalysisPayload
+ ) -> Bool {
+ let outputWords = words(
+ [
+ narrative.title, narrative.finding,
+ narrative.whyItMatters, narrative.recommendation
+ ].joined(separator: " ")
+ )
+ let outputPhrases = Set(phrases(outputWords))
+ for value in payload.sourceContent.values.flatMap({ $0 }) {
+ let sourceWords = words(value)
+ if sourceWords.count >= 4,
+ !outputPhrases.isDisjoint(with: phrases(sourceWords)) {
+ return true
+ }
+ let source = sourceWords.joined(separator: " ")
+ if !source.isEmpty,
+ source.count >= 8,
+ sourceWords.count < 4,
+ outputWords.joined(separator: " ").contains(source) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private static func words(_ text: String) -> [String] {
+ text.lowercased()
+ .split { !$0.isLetter && !$0.isNumber }
+ .map(String.init)
+ }
+
+ private static func phrases(_ words: [String]) -> Set {
+ guard words.count >= 4 else { return [] }
+ return Set((0 ... words.count - 4).map {
+ words[$0 ..< $0 + 4].joined(separator: " ")
+ })
+ }
}
protocol CodexSourceContentReading: Sendable {
diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift
index 3448c13..f9dbdd3 100644
--- a/Sources/CodexLimits/MenuContentView.swift
+++ b/Sources/CodexLimits/MenuContentView.swift
@@ -6,6 +6,7 @@ struct MenuContentView: View {
@ObservedObject var monitor: UsageMonitor
@StateObject private var workspace: AnalyticsWorkspaceStore
@StateObject private var assistedInsights: CodexAssistedInsightStore
+ @StateObject private var updater = AppUpdater()
@Environment(\.openSettings) private var openSettings
init(
@@ -38,6 +39,8 @@ struct MenuContentView: View {
await monitor.setResetReminderEnabled(isEnabled)
}
},
+ availableUpdateVersion: updater.availableVersion,
+ showAvailableUpdate: updater.showAvailableUpdate,
settings: showSettings
)
.padding(.horizontal, 20)
@@ -75,6 +78,9 @@ struct MenuContentView: View {
.padding(.vertical, 12)
}
.frame(width: layout.width, height: layout.height)
+ .task {
+ updater.start()
+ }
.task(id: workspace.state) {
let state = workspace.state
await monitor.setLocalAnalyticsVisible(
@@ -297,6 +303,8 @@ private struct WorkspaceHeader: View {
let resetReminderState: ResetReminderState
let refresh: () -> Void
let setResetReminderEnabled: (Bool) -> Void
+ let availableUpdateVersion: String?
+ let showAvailableUpdate: () -> Void
let settings: () -> Void
var body: some View {
@@ -330,6 +338,18 @@ private struct WorkspaceHeader: View {
.help("Refresh")
.accessibilityLabel("Refresh usage")
+ if let availableUpdateVersion {
+ Button(action: showAvailableUpdate) {
+ Image(systemName: "arrow.down.circle")
+ }
+ .buttonStyle(.borderless)
+ .help("Upgrade to \(availableUpdateVersion)")
+ .accessibilityLabel("Upgrade Codex Limits")
+ .accessibilityValue(
+ "Version \(availableUpdateVersion) is available"
+ )
+ }
+
Button(action: settings) {
Image(systemName: "gearshape")
}
@@ -3999,6 +4019,13 @@ struct InsightsWorkspace: View {
} else if assistedInsights.wasCancelled {
Text("Analysis stopped.")
.foregroundStyle(.secondary)
+ } else if !CodexAssistedEvidenceResolver.canAnalyze(
+ payload: assistedPayload
+ ) {
+ Text(
+ "More usage history is needed before metadata analysis can add useful guidance."
+ )
+ .foregroundStyle(.secondary)
} else {
Text("Ask Codex to analyze the metadata shown here.")
.foregroundStyle(.secondary)
@@ -4012,19 +4039,25 @@ struct InsightsWorkspace: View {
if assistedInsights.showsAnalyzeAction,
!assistedInsights.isRunning {
HStack(spacing: 10) {
- Button(
- assistedInsights.result(for: assistedScope) == nil
- ? "Analyze metadata"
- : "Analyze metadata again"
+ if CodexAssistedEvidenceResolver.canAnalyze(
+ payload: assistedPayload
) {
- assistedInsights.startAnalysis(
- payload: assistedPayload,
- scope: assistedScope
+ Button(
+ assistedInsights.result(
+ for: assistedScope
+ ) == nil
+ ? "Analyze metadata"
+ : "Analyze metadata again"
+ ) {
+ assistedInsights.startAnalysis(
+ payload: assistedPayload,
+ scope: assistedScope
+ )
+ }
+ .accessibilityHint(
+ "Sends bounded metadata to Codex and uses your allowance"
)
}
- .accessibilityHint(
- "Sends bounded metadata to Codex and uses your allowance"
- )
if let sourceSelection {
Button("Analyze Source Content") {
diff --git a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift
index 7bdb04c..d978441 100644
--- a/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift
+++ b/Tests/CodexLimitsTests/CodexAssistedInsightTests.swift
@@ -155,6 +155,17 @@ final class CodexAssistedInsightTests: XCTestCase {
XCTAssertEqual(sandbox["type"] as? String, "readOnly")
XCTAssertEqual(sandbox["networkAccess"] as? Bool, false)
XCTAssertNil(turnParams["multiAgentMode"])
+ let schema = try XCTUnwrap(
+ turnParams["outputSchema"] as? [String: Any]
+ )
+ let properties = try XCTUnwrap(
+ schema["properties"] as? [String: Any]
+ )
+ XCTAssertNotNil(properties["finding"])
+ XCTAssertNotNil(properties["whyItMatters"])
+ XCTAssertNotNil(properties["recommendation"])
+ XCTAssertNotNil(properties["evidenceFields"])
+ XCTAssertNil(properties["insightKind"])
}
func testMetadataPayloadIsBoundedAndContainsNoSourceContent() throws {
@@ -184,6 +195,7 @@ final class CodexAssistedInsightTests: XCTestCase {
"usageRemaining",
"weeklyResetAt",
"evidence",
+ "paceGuidance",
"accountTokenActivity",
"localTokenActivity",
"activity",
@@ -230,6 +242,61 @@ final class CodexAssistedInsightTests: XCTestCase {
}
}
+ func testMetadataPayloadReusesLowCoveragePaceGuidance() throws {
+ let now = Date(timeIntervalSince1970: 2_000_000)
+ let reset = now.addingTimeInterval(5 * 86_400)
+ let reader = UsageIntelligenceEngine.evaluate(
+ UsageIntelligenceInput(
+ account: UsageSnapshot(
+ mainLimit: LimitReading(
+ limitId: "weekly",
+ name: "Weekly",
+ window: UsageWindow(
+ remainingPercent: 37,
+ resetsAt: reset,
+ durationMinutes: 10_080
+ )
+ ),
+ otherLimits: [],
+ tokenHistory: [],
+ emergencyResetCount: 0,
+ fetchedAt: now
+ ),
+ samples: [
+ UsageSample(
+ observedAt: now.addingTimeInterval(-86_400),
+ remainingPercent: 39,
+ resetsAt: reset
+ ),
+ UsageSample(
+ observedAt: now.addingTimeInterval(-43_200),
+ remainingPercent: 38,
+ resetsAt: reset
+ )
+ ],
+ safetyBuffer: 3,
+ sourceState: .available,
+ now: now,
+ previousStatus: nil
+ )
+ )
+
+ let payload = CodexMetadataAnalysisPayload.make(
+ reader: reader,
+ exploration: .initial,
+ now: now
+ )
+ let guidance = try XCTUnwrap(payload.paceGuidance)
+
+ XCTAssertEqual(reader.evidence.coverage, .low)
+ XCTAssertEqual(guidance.status, "roomToUseMore")
+ XCTAssertEqual(guidance.coverage, "low")
+ XCTAssertEqual(guidance.confidence, "low")
+ XCTAssertTrue(
+ CodexAssistedEvidenceResolver.canAnalyze(payload: payload)
+ )
+ }
+
func testInformationTipNamesCodexAndAllowanceUse() {
XCTAssertEqual(
CodexAssistedCopy.informationTip,
@@ -252,6 +319,45 @@ final class CodexAssistedInsightTests: XCTestCase {
XCTAssertEqual(calls.analysisCalls, 0)
}
+ func testLocalUsefulnessGateAvoidsAnAllowanceRequest() async {
+ let service = AssistedServiceFixture(
+ catalogResult: .success(eligibleProfile()),
+ analysisResult: .succeeded(analysisResult())
+ )
+ let store = CodexAssistedInsightStore(service: service)
+ let reader = UsageIntelligenceEngine.evaluate(
+ UsageIntelligenceInput(
+ account: nil,
+ samples: [],
+ safetyBuffer: 3,
+ sourceState: .available,
+ now: Date(timeIntervalSince1970: 2_000),
+ previousStatus: nil
+ )
+ )
+ let payload = CodexMetadataAnalysisPayload.make(
+ reader: reader,
+ exploration: .initial,
+ now: Date(timeIntervalSince1970: 2_000)
+ )
+ await store.checkAvailability()
+
+ XCTAssertFalse(
+ CodexAssistedEvidenceResolver.canAnalyze(payload: payload)
+ )
+ store.startAnalysis(
+ payload: payload,
+ scope: CodexAssistedAnalysisScope(
+ exploration: .initial,
+ payload: payload
+ )
+ )
+
+ let calls = await service.snapshot()
+ XCTAssertEqual(calls.analysisCalls, 0)
+ XCTAssertFalse(store.isRunning)
+ }
+
func testMissingProfileAndCatalogFailureHideTheAction() async {
let missing = AssistedServiceFixture(
catalogResult: .success(nil),
@@ -337,7 +443,17 @@ final class CodexAssistedInsightTests: XCTestCase {
XCTAssertNil(
store.result(
for: analysisScope(
- payload: metadataPayload(generatedAt: 2_001)
+ payload: metadataPayload(weeklyResetAt: 4_000)
+ )
+ )
+ )
+ XCTAssertNotNil(
+ store.result(
+ for: analysisScope(
+ payload: metadataPayload(
+ generatedAt: 2_001,
+ remainingPercent: 36
+ )
)
)
)
@@ -495,6 +611,14 @@ final class CodexAssistedInsightTests: XCTestCase {
accountPartitionID: "account-a"
)
XCTAssertNotNil(restoredStore.result(for: scope))
+ XCTAssertNotNil(
+ restoredStore.result(
+ for: analysisScope(
+ accountPartitionID: "account-a",
+ payload: metadataPayload(remainingPercent: 36)
+ )
+ )
+ )
var filteredExploration = AnalyticsExplorationState.initial
filteredExploration.filters.projectID = "another-project"
let filteredScope = CodexAssistedAnalysisScope(
@@ -842,26 +966,70 @@ final class CodexAssistedInsightTests: XCTestCase {
XCTAssertFalse(store.wasCancelled)
}
- func testResultDecoderRejectsWeakOrUnboundedOutput() throws {
+ func testResultDecoderAcceptsAnalysisAndRejectsWeakOrUnsafeOutput() throws {
+ let decoded = try CodexAssistedResultDecoder.decode(
+ """
+ {
+ "status":"insight",
+ "title":"Allowance intensity increased",
+ "finding":"Usage per token is higher while local activity remains well covered.",
+ "whyItMatters":"The selected work may reach the reset with less useful capacity than the reference period.",
+ "recommendation":"Try a smaller task scope and compare the next bounded period with the same reference.",
+ "evidenceFields":["usage_per_token","local_token_activity"]
+ }
+ """
+ )
+ XCTAssertEqual(decoded.status, .insight)
+ XCTAssertEqual(
+ decoded.evidenceFields,
+ [.usagePerToken, .localTokenActivity]
+ )
XCTAssertEqual(
try CodexAssistedResultDecoder.decode(
- #"{"insightKind":"usage_per_token_change"}"#
- ),
- .usagePerToken
+ """
+ {
+ "status":"insufficient_evidence",
+ "title":"Not enough evidence",
+ "finding":"A comparable reference period is missing.",
+ "whyItMatters":"",
+ "recommendation":"",
+ "evidenceFields":[]
+ }
+ """
+ ).status,
+ .insufficientEvidence
)
XCTAssertThrowsError(
try CodexAssistedResultDecoder.decode(
- #"{"insightKind":"unknown"}"#
+ #"{"insightKind":"usage_per_token_change"}"#
)
)
XCTAssertThrowsError(
try CodexAssistedResultDecoder.decode(
- #"{"insightKind":"activity_summary","summary":"Unsupported free text"}"#
+ """
+ {
+ "status":"insight",
+ "title":"Unsupported cause",
+ "finding":"Usage changed because the tasks were inefficient.",
+ "whyItMatters":"Useful capacity may be lower.",
+ "recommendation":"Try a smaller scope.",
+ "evidenceFields":["usage_per_token","local_token_activity"]
+ }
+ """
)
)
XCTAssertThrowsError(
try CodexAssistedResultDecoder.decode(
- #"{"title":"Pattern","summary":"A weak guess","evidenceFields":["usage_per_token"]}"#
+ """
+ {
+ "status":"insight",
+ "title":"One visible fact",
+ "finding":"Usage remaining is available.",
+ "whyItMatters":"The weekly window is active.",
+ "recommendation":"Try checking it later.",
+ "evidenceFields":["usage_remaining"]
+ }
+ """
)
)
}
@@ -875,6 +1043,7 @@ final class CodexAssistedInsightTests: XCTestCase {
usageRemaining: weak.usageRemaining,
weeklyResetAt: weak.weeklyResetAt,
evidence: weak.evidence,
+ paceGuidance: weak.paceGuidance,
accountTokenActivity: weak.accountTokenActivity,
localTokenActivity: .init(
tokens: weak.localTokenActivity.tokens,
@@ -888,19 +1057,64 @@ final class CodexAssistedInsightTests: XCTestCase {
)
XCTAssertNil(
CodexAssistedEvidenceResolver.resolve(
- kind: .localTokenActivity,
+ selection: metadataSelection(
+ evidenceFields: [
+ .localTokenActivity,
+ .usageRemaining
+ ]
+ ),
payload: weak
)
)
let resolved = CodexAssistedEvidenceResolver.resolve(
- kind: .usagePerToken,
+ selection: metadataSelection(
+ evidenceFields: [.usagePerToken, .usageRemaining]
+ ),
payload: metadataPayload()
)
- XCTAssertEqual(resolved?.title, "Usage per token")
+ XCTAssertEqual(resolved?.title, "Allowance intensity increased")
XCTAssertEqual(
resolved?.summary,
- "Usage per token is 1.25× the reference for this period."
+ "Usage per token is higher than the reference while usage remains available."
+ )
+ XCTAssertEqual(resolved?.evidence.count, 4)
+ XCTAssertEqual(resolved?.confidence, .high)
+ let withheld = CodexAssistedEvidenceResolver.resolve(
+ selection: CodexMetadataInsightSelection(
+ status: .insufficientEvidence,
+ title: "Not enough evidence",
+ finding: "A comparable reference period is missing.",
+ whyItMatters: "",
+ recommendation: "",
+ evidenceFields: []
+ ),
+ payload: metadataPayload()
)
+ XCTAssertEqual(withheld?.title, "Not enough evidence")
+ XCTAssertEqual(withheld?.confidence, .unavailable)
+ }
+
+ func testLowCoveragePaceGuidanceStillProducesCautiousAction() {
+ let payload = lowCoveragePacePayload()
+ let resolved = CodexAssistedEvidenceResolver.resolve(
+ selection: CodexMetadataInsightSelection(
+ status: .insight,
+ title: "Use available headroom",
+ finding: "The observed pace is below the sustainable pace for this window.",
+ whyItMatters: "Unused headroom may remain when the allowance resets.",
+ recommendation: "Try moving closer to the recommended pace, then compare the refreshed forecast.",
+ evidenceFields: [.paceGuidance, .usageRemaining]
+ ),
+ payload: payload
+ )
+
+ XCTAssertTrue(
+ CodexAssistedEvidenceResolver.canAnalyze(payload: payload)
+ )
+ XCTAssertEqual(resolved?.title, "Use available headroom")
+ XCTAssertEqual(resolved?.coverage, .low)
+ XCTAssertEqual(resolved?.confidence, .low)
+ XCTAssertEqual(resolved?.evidence.count, 4)
}
func testLiveClientReadsCatalogAndRunsOneEphemeralAnalysis() async throws {
@@ -922,7 +1136,7 @@ final class CodexAssistedInsightTests: XCTestCase {
let requests = fixture.snapshot()
XCTAssertEqual(result.source, "Codex-assisted")
- XCTAssertEqual(result.title, "Usage per token")
+ XCTAssertEqual(result.title, "Allowance intensity increased")
XCTAssertEqual(result.overhead.durationSeconds, 12)
XCTAssertEqual(
result.overhead.accountMovement,
@@ -971,7 +1185,7 @@ final class CodexAssistedInsightTests: XCTestCase {
XCTAssertEqual(requests.rateLimitReads, 2)
}
- func testLiveClientRejectsUnknownEnabledFeatureBeforeThreadStart() async {
+ func testLiveClientDisablesUnknownEnabledFeature() async {
let fixture = CodexAssistedProtocolFixture(
addsUnknownEnabledFeature: true
)
@@ -986,12 +1200,40 @@ final class CodexAssistedInsightTests: XCTestCase {
profile: eligibleProfile()
)
- guard case .failed = outcome else {
- return XCTFail("Expected an unknown feature to fail closed")
+ guard case .succeeded = outcome else {
+ return XCTFail("Expected the advertised feature to be disabled")
}
let requests = fixture.snapshot()
- XCTAssertEqual(requests.threadStartCount, 0)
- XCTAssertEqual(requests.turnStartCount, 0)
+ XCTAssertEqual(requests.threadStartCount, 1)
+ XCTAssertEqual(requests.turnStartCount, 1)
+ XCTAssertTrue(requests.isolationVerified)
+ }
+
+ func testLiveClientRejectsUntrustedFeatureLists() async {
+ for featureList in [
+ FeatureListFixture.malformed,
+ .failed
+ ] {
+ let fixture = CodexAssistedProtocolFixture(
+ featureList: featureList
+ )
+ let client = CodexAssistedClient(
+ makeConnection: { try fixture.makeConnection() },
+ timeout: 1,
+ now: { fixture.now() }
+ )
+
+ let outcome = await client.analyze(
+ payload: metadataPayload(),
+ profile: eligibleProfile()
+ )
+
+ guard case .failed = outcome else {
+ XCTFail("Expected the untrusted feature list to fail closed")
+ continue
+ }
+ XCTAssertEqual(fixture.snapshot().threadStartCount, 0)
+ }
}
func testLiveClientRequiresAnExplicitEmptyInstructionSourceList() async throws {
@@ -1250,22 +1492,35 @@ final class CodexAssistedInsightTests: XCTestCase {
}
private func metadataPayload(
- generatedAt: Int64 = 2_000
+ generatedAt: Int64 = 2_000,
+ remainingPercent: Double = 37,
+ weeklyResetAt: Int64 = 3_000
) -> CodexMetadataAnalysisPayload {
CodexMetadataAnalysisPayload(
schemaVersion: 1,
generatedAt: generatedAt,
range: .init(start: 1_000, end: 2_000),
usageRemaining: .init(
- percent: 37,
- interval: .init(start: 1_000, end: 3_000)
+ percent: remainingPercent,
+ interval: .init(start: 1_000, end: weeklyResetAt)
),
- weeklyResetAt: 3_000,
+ weeklyResetAt: weeklyResetAt,
evidence: .init(
freshness: "fresh",
coverage: "high",
confidence: "high"
),
+ paceGuidance: .init(
+ status: "roomToUseMore",
+ expectedRemainingAtReset: 14,
+ safetyRemainingAtReset: 10,
+ recommendedPercentPerDay: 8,
+ currentPercentPerDay: 5,
+ historicalPercentPerDay: 6,
+ historicalReferenceSource: "Account history",
+ coverage: "high",
+ confidence: "high"
+ ),
accountTokenActivity: .init(
tokens: 1_200_000,
state: "exact",
@@ -1309,6 +1564,77 @@ final class CodexAssistedInsightTests: XCTestCase {
)
}
+ private func metadataSelection(
+ evidenceFields: [CodexMetadataEvidenceField]
+ ) -> CodexMetadataInsightSelection {
+ CodexMetadataInsightSelection(
+ status: .insight,
+ title: "Allowance intensity increased",
+ finding: "Usage per token is higher than the reference while usage remains available.",
+ whyItMatters: "The selected work may reach the reset with less useful capacity than the reference period.",
+ recommendation: "Try a smaller task scope and compare the next bounded period with the same reference.",
+ evidenceFields: evidenceFields
+ )
+ }
+
+ private func lowCoveragePacePayload() -> CodexMetadataAnalysisPayload {
+ let base = metadataPayload()
+ return CodexMetadataAnalysisPayload(
+ schemaVersion: base.schemaVersion,
+ generatedAt: base.generatedAt,
+ range: base.range,
+ usageRemaining: base.usageRemaining,
+ weeklyResetAt: base.weeklyResetAt,
+ evidence: .init(
+ freshness: "fresh",
+ coverage: "low",
+ confidence: "low"
+ ),
+ paceGuidance: .init(
+ status: "roomToUseMore",
+ expectedRemainingAtReset: 14,
+ safetyRemainingAtReset: 10,
+ recommendedPercentPerDay: 8,
+ currentPercentPerDay: 5,
+ historicalPercentPerDay: nil,
+ historicalReferenceSource: nil,
+ coverage: "low",
+ confidence: "low"
+ ),
+ accountTokenActivity: .init(
+ tokens: nil,
+ state: "unavailable",
+ interval: nil
+ ),
+ localTokenActivity: .init(
+ tokens: nil,
+ coverage: "unavailable",
+ interval: nil
+ ),
+ activity: .init(
+ activeSeconds: nil,
+ peakConcurrentTasks: nil,
+ coverage: "unavailable",
+ interval: nil
+ ),
+ usagePerToken: .init(
+ multiplier: nil,
+ coverage: "unavailable",
+ confidence: "unavailable",
+ currentInterval: nil,
+ referenceInterval: nil
+ ),
+ activeTimeAvailable: .init(
+ lowerSeconds: nil,
+ upperSeconds: nil,
+ coverage: "unavailable",
+ confidence: "unavailable",
+ observedInterval: nil
+ ),
+ scope: base.scope
+ )
+ }
+
private func analysisScope(
accountPartitionID: String? = nil,
payload: CodexMetadataAnalysisPayload? = nil
@@ -1524,12 +1850,19 @@ private enum InstructionSourcesFixture {
case malformed
}
+private enum FeatureListFixture {
+ case valid
+ case malformed
+ case failed
+}
+
private final class CodexAssistedProtocolFixture: @unchecked Sendable {
private let lock = NSLock()
private let sendsToolCall: Bool
private let sendsUnknownItem: Bool
private let dropsCatalogConnection: Bool
private let addsUnknownEnabledFeature: Bool
+ private let featureList: FeatureListFixture
private let delaysThreadResponse: Bool
private let accountIsMissing: Bool
private let instructionSources: InstructionSourcesFixture
@@ -1553,6 +1886,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable {
sendsUnknownItem: Bool = false,
dropsCatalogConnection: Bool = false,
addsUnknownEnabledFeature: Bool = false,
+ featureList: FeatureListFixture = .valid,
delaysThreadResponse: Bool = false,
accountIsMissing: Bool = false,
instructionSources: InstructionSourcesFixture = .empty,
@@ -1563,6 +1897,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable {
self.sendsUnknownItem = sendsUnknownItem
self.dropsCatalogConnection = dropsCatalogConnection
self.addsUnknownEnabledFeature = addsUnknownEnabledFeature
+ self.featureList = featureList
self.delaysThreadResponse = delaysThreadResponse
self.accountIsMissing = accountIsMissing
self.instructionSources = instructionSources
@@ -1649,7 +1984,14 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable {
let unknown = addsUnknownEnabledFeature
? #",{"name":"future_tool","stage":"stable","enabled":true,"defaultEnabled":true}"#
: ""
- response = #"{"id":\#(id),"result":{"data":[{"name":"apps","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"multi_agent","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"fast_mode","stage":"stable","enabled":true,"defaultEnabled":true}\#(unknown)],"nextCursor":null}}"#
+ response = switch featureList {
+ case .valid:
+ #"{"id":\#(id),"result":{"data":[{"name":"apps","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"multi_agent","stage":"stable","enabled":true,"defaultEnabled":true},{"name":"fast_mode","stage":"stable","enabled":true,"defaultEnabled":true}\#(unknown)],"nextCursor":null}}"#
+ case .malformed:
+ #"{"id":\#(id),"result":{"data":[{"name":"apps"}],"nextCursor":null}}"#
+ case .failed:
+ #"{"id":\#(id),"error":{"code":-32603,"message":"feature list unavailable"}}"#
+ }
case "config/read":
lock.withLock { configReads += 1 }
response = #"{"id":\#(id),"result":{"config":{"mcp_servers":{"local-server":{"enabled":true}}},"origins":{}}}"#
@@ -1661,11 +2003,16 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable {
as? [String: Bool]
let servers = config?["mcp_servers"]
as? [String: [String: Bool]]
+ let expectedFeatures = Dictionary(
+ uniqueKeysWithValues: (
+ ["apps", "multi_agent", "fast_mode"]
+ + (addsUnknownEnabledFeature
+ ? ["future_tool"]
+ : [])
+ ).map { ($0, false) }
+ )
isolationVerified =
- features == [
- "apps": false,
- "multi_agent": false
- ]
+ features == expectedFeatures
&& servers == [
"local-server": ["enabled": false]
]
@@ -1707,7 +2054,7 @@ private final class CodexAssistedProtocolFixture: @unchecked Sendable {
"item": [
"id": "message-1",
"type": "agentMessage",
- "text": #"{"insightKind":"usage_per_token_change"}"#
+ "text": #"{"status":"insight","title":"Allowance intensity increased","finding":"Usage per token is higher than the reference while local activity remains well covered.","whyItMatters":"The selected work may reach the reset with less useful capacity than the reference period.","recommendation":"Try a smaller task scope and compare the next bounded period with the same reference.","evidenceFields":["usage_per_token","local_token_activity"]}"#
]
]
],
diff --git a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift
index cecb9c9..54844e9 100644
--- a/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift
+++ b/Tests/CodexLimitsTests/CodexSourceAnalysisTests.swift
@@ -142,7 +142,7 @@ final class CodexSourceAnalysisTests: XCTestCase {
)
}
- func testSourceResultUsesOnlyValidatedReferencesAndDerivedCopy() throws {
+ func testSourceResultUsesValidatedReferencesWithoutExposingContent() throws {
let draft = CodexSourceContentDraft(
selection: sourceSelection(),
values: [
@@ -158,7 +158,11 @@ final class CodexSourceAnalysisTests: XCTestCase {
let result = try CodexSourceResultDecoder.decode(
"""
{
- "sourceInsightKind":"repeated_work",
+ "status":"insight",
+ "title":"Repeated effort",
+ "finding":"Several selected exchanges revisit the same requested outcome.",
+ "whyItMatters":"Repeated exchanges can make the task harder to verify.",
+ "recommendation":"Try stating the acceptance check before the next similar task.",
"evidence":[
{"category":"prompts","itemNumbers":[1,2]},
{"category":"commands","itemNumbers":[1]}
@@ -169,14 +173,39 @@ final class CodexSourceAnalysisTests: XCTestCase {
metadata: metadataPayload()
)
- XCTAssertEqual(result.title, "Repeated work")
+ XCTAssertEqual(result.title, "Repeated effort")
XCTAssertEqual(
result.evidence,
- ["Prompts · items 1, 2", "Commands · item 1"]
+ [
+ "Why it matters: Repeated exchanges can make the task harder to verify.",
+ "Try next: Try stating the acceptance check before the next similar task.",
+ "Prompts · items 1, 2",
+ "Commands · item 1"
+ ]
)
XCTAssertEqual(result.confidence, .high)
XCTAssertFalse(result.summary.contains("Private prompt"))
XCTAssertFalse(result.evidence.joined().contains("swift test"))
+
+ XCTAssertThrowsError(
+ try CodexSourceResultDecoder.decode(
+ """
+ {
+ "status":"insight",
+ "title":"Repeated effort",
+ "finding":"Private prompt Follow-up appears again.",
+ "whyItMatters":"Repeated exchanges can hide the acceptance check.",
+ "recommendation":"Try stating the check before the next task.",
+ "evidence":[
+ {"category":"prompts","itemNumbers":[1,2]},
+ {"category":"commands","itemNumbers":[1]}
+ ]
+ }
+ """,
+ payload: payload,
+ metadata: metadataPayload()
+ )
+ )
}
func testSourceResultRejectsMissingOrOutOfRangeEvidence() throws {
@@ -192,7 +221,11 @@ final class CodexSourceAnalysisTests: XCTestCase {
try CodexSourceResultDecoder.decode(
"""
{
- "sourceInsightKind":"repeated_work",
+ "status":"insight",
+ "title":"Repeated effort",
+ "finding":"Several selected exchanges revisit the same requested outcome.",
+ "whyItMatters":"Repeated exchanges can make the task harder to verify.",
+ "recommendation":"Try stating the acceptance check before the next similar task.",
"evidence":[
{"category":"prompts","itemNumbers":[1]},
{"category":"prompts","itemNumbers":[2]}
@@ -205,6 +238,51 @@ final class CodexSourceAnalysisTests: XCTestCase {
)
}
+ func testSourceResultWithholdsWhenEvidenceIsInsufficient() throws {
+ let payload = try CodexSourceAnalysisPayload(
+ draft: CodexSourceContentDraft(
+ selection: sourceSelection(),
+ values: [.prompts: ["Only item"]]
+ ),
+ categories: [.prompts]
+ )
+
+ let result = try CodexSourceResultDecoder.decode(
+ """
+ {
+ "status":"insufficient_evidence",
+ "title":"Not enough evidence",
+ "finding":"The selected content does not contain a repeated supported pattern.",
+ "whyItMatters":"",
+ "recommendation":"",
+ "evidence":[]
+ }
+ """,
+ payload: payload,
+ metadata: metadataPayload()
+ )
+
+ XCTAssertEqual(result.title, "Not enough evidence")
+ XCTAssertEqual(result.confidence, .unavailable)
+ XCTAssertTrue(result.evidence.isEmpty)
+ XCTAssertThrowsError(
+ try CodexSourceResultDecoder.decode(
+ """
+ {
+ "status":"insufficient_evidence",
+ "title":"Not enough evidence",
+ "finding":"Only item does not show a repeated supported pattern.",
+ "whyItMatters":"",
+ "recommendation":"",
+ "evidence":[]
+ }
+ """,
+ payload: payload,
+ metadata: metadataPayload()
+ )
+ )
+ }
+
func testEmptyScopeReadsNothing() async {
let recorder = SourceRequestRecorder()
let reader = CodexSourceContentReader { request in
@@ -797,7 +875,10 @@ final class CodexSourceAnalysisTests: XCTestCase {
XCTAssertFalse(text.contains("\"responses\""))
XCTAssertFalse(text.contains("\"paths\""))
XCTAssertFalse(text.contains("\"metadata\""))
- XCTAssertNotNil(properties["sourceInsightKind"])
+ XCTAssertNotNil(properties["status"])
+ XCTAssertNotNil(properties["finding"])
+ XCTAssertNotNil(properties["whyItMatters"])
+ XCTAssertNotNil(properties["recommendation"])
XCTAssertNotNil(properties["evidence"])
XCTAssertNil(properties["insightKind"])
}
@@ -971,6 +1052,17 @@ final class CodexSourceAnalysisTests: XCTestCase {
coverage: "high",
confidence: "high"
),
+ paceGuidance: .init(
+ status: "roomToUseMore",
+ expectedRemainingAtReset: 14,
+ safetyRemainingAtReset: 10,
+ recommendedPercentPerDay: 8,
+ currentPercentPerDay: 5,
+ historicalPercentPerDay: 6,
+ historicalReferenceSource: "Account history",
+ coverage: "high",
+ confidence: "high"
+ ),
accountTokenActivity: .init(
tokens: 1_000,
state: "exact",
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..4017d1f
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,25 @@
+# Releasing Codex Limits
+
+Releases are prepared by the manual `Release` GitHub Actions workflow. It builds one universal macOS app, signs the update archive and feed with Sparkle EdDSA, and stops at a Draft GitHub Release.
+
+## One-time setup
+
+1. Create a protected GitHub environment named `release` and restrict it to the `main` branch.
+2. Store the exported Sparkle private key as the environment secret `SPARKLE_PRIVATE_KEY`.
+3. Store a second copy of the private key in Bitwarden. Never commit or paste it into an issue, pull request, workflow input, or chat.
+4. Keep the public key in `Resources/Info.plist`.
+
+Losing the EdDSA private key prevents ad-hoc-signed installations from trusting future updates. Keep both protected copies.
+
+## Release flow
+
+1. Ask Codex to prepare a release and provide the stable version number.
+2. Codex runs tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`.
+3. Run `Scripts/validate-release.sh VERSION` and the `Release` workflow with `dry_run` enabled.
+4. Inspect the universal app archive, signed `appcast.xml`, generated notes, and workflow result.
+5. Run the workflow with `dry_run` disabled. It creates a Draft Release only.
+6. Inspect the draft and explicitly tell Codex to publish it.
+
+Publishing a stable GitHub Release makes its `appcast.xml` available through the repository's `releases/latest/download` URL. Drafts and prereleases are not returned by that URL.
+
+The app and update archive are ad-hoc signed because this project has no Apple Developer ID certificate. Sparkle still verifies the EdDSA-signed feed and archive. A user's first manual installation remains subject to macOS Gatekeeper; in-app updates do not remove that first-install limitation.