Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
360 changes: 311 additions & 49 deletions Pines/Agents/AgentRunner.swift

Large diffs are not rendered by default.

483 changes: 422 additions & 61 deletions Pines/App/PinesAppModel+MCP.swift

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Pines/App/PinesAppModel+Presentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ extension PinesAppModel {
status: PinesThreadStatus? = nil,
updatedAt: Date? = nil
) -> PinesThreadPreview {
let messages = messages.filter { !$0.isContextOnly }
let lastMessage = previewText(for: messages.last)
return PinesThreadPreview(
id: record.id,
Expand Down Expand Up @@ -67,6 +68,7 @@ extension PinesAppModel {
status: PinesThreadStatus? = nil,
updatedAt: Date = Date()
) -> PinesThreadPreview {
let messages = messages.filter { !$0.isContextOnly }
let lastMessage = previewText(for: messages.last)
let resolvedStatus = existing.status == .archived ? PinesThreadStatus.archived : (status ?? existing.status)
return PinesThreadPreview(
Expand Down Expand Up @@ -95,7 +97,7 @@ extension PinesAppModel {
}

static func threadTokenCount(_ messages: [ChatMessage]) -> Int {
messages.reduce(0) { $0 + max(1, $1.content.split(separator: " ").count) }
messages.lazy.filter { !$0.isContextOnly }.reduce(0) { $0 + max(1, $1.content.split(separator: " ").count) }
}

static func previewText(for message: ChatMessage?) -> String? {
Expand Down
2 changes: 1 addition & 1 deletion Pines/App/PinesAppModel+Stress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ extension PinesAppModel {
var lastAssistant: ChatMessage?
repeat {
let messages = try await repository.messages(in: conversationID)
lastAssistant = messages.last { $0.role == .assistant }
lastAssistant = messages.last { $0.role == .assistant && !$0.isContextOnly }
if lastAssistant?.persistedMessageStatus != .streaming {
return lastAssistant
}
Expand Down
486 changes: 280 additions & 206 deletions Pines/App/PinesAppModel.swift

Large diffs are not rendered by default.

8 changes: 0 additions & 8 deletions Pines/App/PinesAppModelTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,6 @@ struct PinesThreadPreview: Identifiable, Hashable {
let updatedLabel: String
let tokenCount: Int

var request: ChatRequest {
ChatRequest(
modelID: modelID,
messages: messages,
allowsTools: false,
vaultContextIDs: []
)
}
}

struct PinesProjectPreview: Identifiable, Hashable {
Expand Down
85 changes: 84 additions & 1 deletion Pines/App/PinesRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ private struct CloudContextApprovalSheet: View {
LabeledContent("Model", value: request.modelID.rawValue)
LabeledContent("Vault documents", value: "\(request.documentIDs.count)")
LabeledContent("MCP resources", value: "\(request.mcpResourceIDs.count)")
if let count = request.localTranscriptMessageCount, count > 0 {
LabeledContent("Private transcript rows", value: "\(count)")
}
LabeledContent(
"Context size",
value: ByteCountFormatter.string(
Expand All @@ -764,7 +767,7 @@ private struct CloudContextApprovalSheet: View {
}

Section("Privacy") {
Text("Selected local vault and MCP resource context can be sent to this cloud provider for this turn.")
Text("Selected local Vault, MCP resources, and private tool or retrieved context retained from earlier runs can be sent to this cloud provider for this turn.")
.font(theme.typography.body)
.foregroundStyle(theme.colors.secondaryText)
}
Expand Down Expand Up @@ -851,6 +854,35 @@ private struct MCPSamplingApprovalSheet: View {
.pinesExpressiveScrollHaptics()
}

if let systemPrompt = request.systemPrompt,
!systemPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Section("Server-provided reference prompt") {
Text(systemPrompt)
.font(theme.typography.code)
.foregroundStyle(theme.colors.secondaryText)
.textSelection(.enabled)
Text("This text is treated as untrusted reference data, never as a Pines system instruction.")
.font(theme.typography.caption)
.foregroundStyle(theme.colors.tertiaryText)
}
}

if !payloadDisclosures.isEmpty {
Section("Request payloads") {
ForEach(Array(payloadDisclosures.enumerated()), id: \.offset) { _, disclosure in
Text(disclosure)
.font(theme.typography.caption)
.foregroundStyle(theme.colors.secondaryText)
}
}
}

Section("Privacy & context") {
Text("Pines does not add ambient chat or Vault history to MCP sampling. It tries a compatible local model first. If local sampling is unavailable and BYOK sampling is enabled for this server, the approved prompt may be sent to your configured cloud provider.")
.font(theme.typography.caption)
.foregroundStyle(theme.colors.secondaryText)
}

if let preferences = request.modelPreferences {
Section("Model Preferences") {
Text(String(describing: preferences))
Expand All @@ -873,6 +905,57 @@ private struct MCPSamplingApprovalSheet: View {
}
.pinesDismissKeyboardOnSwipeDown()
}

private var payloadDisclosures: [String] {
request.messages.enumerated().flatMap { index, message in
disclosures(
for: message.content,
prefix: "Message \(index + 1) (\(message.role.rawValue))"
)
}
}

private func disclosures(for contents: [MCPMessageContent], prefix: String) -> [String] {
disclosures(for: contents, prefix: prefix, depth: 0)
}

private func disclosures(
for contents: [MCPMessageContent],
prefix: String,
depth: Int
) -> [String] {
guard depth <= 16 else { return ["\(prefix): nested content limit exceeded"] }
return contents.flatMap { content -> [String] in
switch content {
case let .image(data, mimeType):
return ["\(prefix): image \(mimeType), approximately \(estimatedBase64DecodedBytes(data)) bytes"]
case let .audio(data, mimeType):
return ["\(prefix): audio \(mimeType), approximately \(estimatedBase64DecodedBytes(data)) bytes"]
case let .resource(resource):
if let blob = resource.blob {
return ["\(prefix): resource \(resource.uri), \(resource.mimeType ?? "unknown type"), approximately \(estimatedBase64DecodedBytes(blob)) bytes"]
}
return resource.text == nil ? ["\(prefix): resource \(resource.uri)"] : []
case let .toolUse(id, name, _):
return ["\(prefix): tool use \(id) (\(name))"]
case let .toolResult(toolUseID, nested):
return disclosures(
for: nested,
prefix: "\(prefix), tool result \(toolUseID)",
depth: depth + 1
)
case .unknown:
return ["\(prefix): unsupported content"]
case .text:
return []
}
}
}

private func estimatedBase64DecodedBytes(_ encoded: String) -> Int {
let count = encoded.utf8.count
return (count / 4) * 3 + min(2, count % 4)
}
}

private struct MCPSamplingResultReviewSheet: View {
Expand Down
5 changes: 4 additions & 1 deletion Pines/Cloud/BYOKCloudInferenceProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1099,7 +1099,7 @@ struct BYOKCloudInferenceProvider: InferenceProvider {
}
}

private static func anthropicWebSearchToolObject(options: CloudWebSearchOptions?) -> [String: Any] {
static func anthropicWebSearchToolObject(options: CloudWebSearchOptions?) -> [String: Any] {
let resolvedOptions = options ?? CloudWebSearchOptions()
var tool: [String: Any] = [
"type": "web_search_20250305",
Expand Down Expand Up @@ -1425,6 +1425,9 @@ struct BYOKCloudInferenceProvider: InferenceProvider {

private func openAICompletionTokenLimit(for chatRequest: ChatRequest, usesReasoningParameters: Bool) -> Int {
let requested = chatRequest.sampling.maxTokens ?? 1024
if chatRequest.executionContext == .sampling {
return requested
}
guard usesReasoningParameters else { return requested }
return max(requested, Self.openAIReasoningDefaultMaxCompletionTokens)
}
Expand Down
10 changes: 10 additions & 0 deletions Pines/Cloud/PinesManagedCloudService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ struct PinesManagedCloudService: Sendable {
guard availability.supports(.chat) else {
throw InferenceError.cloudNotAllowed
}
try Self.validateChatWireContract(request)
guard let url = endpoint("/v1/chat/stream") else {
throw InferenceError.invalidRequest("Managed Pro Cloud gateway is not configured.")
}
Expand Down Expand Up @@ -121,9 +122,18 @@ struct PinesManagedCloudService: Sendable {
guard availability.supports(.tokenPreflight) else {
throw InferenceError.cloudNotAllowed
}
try Self.validateChatWireContract(request)
return try await postJSON(ManagedCloudChatRequest(request: request), path: "/v1/tokens/count")
}

private static func validateChatWireContract(_ request: ChatRequest) throws {
guard request.messages.allSatisfy({ $0.attachments.isEmpty }) else {
throw InferenceError.unsupportedCapability(
"Managed Pro Cloud chat attachments require an upload wire contract that is not available in this build. Use local or BYOK chat, or the dedicated cloud file-analysis workflow."
)
}
}

func capabilityManifest() async throws -> ManagedCloudCapabilityManifest {
try await getJSON(path: "/v1/capabilities")
}
Expand Down
3 changes: 2 additions & 1 deletion Pines/Persistence/GRDBPinesStore+Mapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,8 @@ extension GRDBPinesStore {
title: row["title"],
sourceType: row["source_type"],
updatedAt: Date(timeIntervalSinceReferenceDate: row["updated_at"]),
chunkCount: 0
chunkCount: 0,
projectID: (row["project_id"] as String?).flatMap(UUID.init(uuidString:))
)
let chunk = VaultChunk(
id: row["chunk_id"],
Expand Down
Loading
Loading