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
2 changes: 1 addition & 1 deletion Sources/AppleNotesBlade/AppleNotesBlade.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ import Foundation
public enum AppleNotesBlade {
/// Library version. Bump together with the git tag.
/// See `Version.swift` for the canonical SemVer string.
public static let version = "0.1.0"
public static let version = "0.2.0"
}
160 changes: 160 additions & 0 deletions Sources/AppleNotesBlade/MetaEnvelope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// MetaEnvelope.swift
//
// DD-338 Phase C Wave 5 — canonical `_meta:` envelope helper for the Swift
// blade-mcp class. Establishes precedent for all future first-party Swift
// blades. Duplicated byte-equivalent across `apple-mail-blade-mcp` and
// `apple-notes-blade-mcp` per DD-240 invariant #8 (no cross-blade-mcp deps).
//
// Wire shape:
//
// <existing-JSON-payload>
//
// _meta: {"matched_total": 42, "returned": 10, "filtered_by": [...], "latency_ms": 87}
//
// Required fields: `matched_total: Int`, `returned: Int`, `filtered_by: [String]`,
// `latency_ms: Int`. Optional fields (`redactions`, `next_cursor`, `error_notes`)
// are OMITTED when empty/nil per Wave 3 OQ-1 ratification.
//
// Cross-language byte-non-equivalence with Python (`json.dumps(separators=
// (", ", ": "))`) and TS (`JSON.stringify`) is ACCEPTED per Wave 5 OQ-1
// ratification — the assembler regex `\n\n_meta: (\{.*\})$` parses the JSON
// object, it does not byte-hash the line. See DEVFU
// `2026-05-23-meta-envelope-byte-equivalence-cross-language`.
//
// Reference helpers:
// - Python: `gmail-blade-mcp/src/gmail_blade_mcp/server.py` (_format_meta_envelope)
// - TS: `cloudflare-blade-mcp/src/utils/meta.ts` (formatMetaLine)

import CryptoKit
import Foundation
import MCP

// MARK: - Envelope value type

/// Canonical `_meta:` envelope value. `Sendable` for actor reach.
///
/// Construction discipline: callers pre-sort `filteredBy` alphabetically for
/// hash reproducibility (the helper does NOT re-sort on emit — sort site is
/// the caller's contract per Wave 3 ratification). Optional arrays passed as
/// `nil` OR `[]` are omitted from the emitted JSON.
public struct MetaEnvelope: Sendable {
public let matchedTotal: Int
public let returned: Int
public let filteredBy: [String]
public let latencyMs: Int
public let redactions: [String]?
public let nextCursor: String?
public let errorNotes: [String]?

public init(
matchedTotal: Int,
returned: Int,
filteredBy: [String],
latencyMs: Int,
redactions: [String]? = nil,
nextCursor: String? = nil,
errorNotes: [String]? = nil
) {
self.matchedTotal = matchedTotal
self.returned = returned
self.filteredBy = filteredBy
self.latencyMs = latencyMs
self.redactions = redactions
self.nextCursor = nextCursor
self.errorNotes = errorNotes
}
}

// MARK: - Wire formatting

/// Format a `_meta:` envelope line. Single-line JSON. The caller MUST prepend
/// `\n\n` when appending to an existing payload (canonical separator).
///
/// Optional fields (`redactions`, `next_cursor`, `error_notes`) are omitted
/// when empty/nil. JSON top-level keys are sorted alphabetically via
/// `JSONEncoder.OutputFormatting.sortedKeys` for byte-reproducibility within
/// the Swift implementation. (Cross-language byte equality with Python+TS is
/// not guaranteed — see file header.)
public func formatMetaLine(_ meta: MetaEnvelope) -> String {
var shadow = MetaEnvelopeShadow(
matched_total: meta.matchedTotal,
returned: meta.returned,
filtered_by: meta.filteredBy,
latency_ms: meta.latencyMs
)
if let redactions = meta.redactions, !redactions.isEmpty {
shadow.redactions = redactions
}
if let nextCursor = meta.nextCursor {
shadow.next_cursor = nextCursor
}
if let errorNotes = meta.errorNotes, !errorNotes.isEmpty {
shadow.error_notes = errorNotes
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let data: Data
do {
data = try encoder.encode(shadow)
} catch {
// Encoding a struct of primitives + arrays-of-strings cannot fail in
// practice; fall back to a minimal stub on the off chance.
return #"_meta: {"matched_total":0,"returned":0,"filtered_by":[],"latency_ms":0}"#
}
let json = String(data: data, encoding: .utf8) ?? "{}"
return "_meta: " + json
}

/// Append a `_meta:` envelope line to an existing payload using the canonical
/// `\n\n` separator. Use at every Track-promoted tool-handler site; do NOT
/// inline the concatenation.
public func appendMeta(_ payload: String, _ metaLine: String) -> String {
return "\(payload)\n\n\(metaLine)"
}

/// Shadow struct for `JSONEncoder` — uses snake_case property names so the
/// emitted keys match the wire-shape spec without per-key `CodingKeys` boilerplate.
/// Top-level key ordering is irrelevant because `JSONEncoder.OutputFormatting`
/// `.sortedKeys` is applied on emit.
private struct MetaEnvelopeShadow: Encodable {
let matched_total: Int // swiftlint:disable:this identifier_name
let returned: Int
let filtered_by: [String] // swiftlint:disable:this identifier_name
let latency_ms: Int // swiftlint:disable:this identifier_name
var redactions: [String]?
var next_cursor: String? // swiftlint:disable:this identifier_name
var error_notes: [String]? // swiftlint:disable:this identifier_name
}

// MARK: - Latency timing

extension Duration {
/// Convert a `ContinuousClock.Duration` (or any `Duration`) to integer
/// milliseconds. Truncates sub-millisecond fractions. Used to populate
/// `MetaEnvelope.latencyMs`.
public func toMilliseconds() -> Int {
let comps = self.components
// attoseconds = 1e-18 s; 1 ms = 1e-3 s = 1e15 attoseconds.
let msFromSeconds = comps.seconds * 1000
let msFromAttoseconds = comps.attoseconds / 1_000_000_000_000_000
return Int(msFromSeconds + msFromAttoseconds)
}
}

// MARK: - Query digest

/// Compute a SHA-256-based 12-character digest of a query string, suitable for
/// `filtered_by` `query=` audit values. Privacy-safe + token-bound +
/// collision-resistant within session window. Mirrors W3 OQ-6 ratification for
/// `cf_d1_query`. The digest is non-reversible — the assembler audit trail
/// records that a search was performed without recording the verbatim query
/// bytes.
public func metaQueryDigest(_ query: String) -> String {
let data = Data(query.utf8)
let digest = SHA256.hash(data: data)
let base64 = Data(digest).base64EncodedString()
// Strip non-alphanumeric chars (`+`, `/`, `=`) to keep the audit value
// path-safe and grep-friendly, then take the first 12 characters.
let stripped = base64.filter { $0.isLetter || $0.isNumber }
return String(stripped.prefix(12))
}
21 changes: 19 additions & 2 deletions Sources/AppleNotesBlade/Tools/ListFolders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Foundation
import MCP

/// Handler for `apple_notes_list_folders`. Optional `account_id` filter.
///
/// DD-338 Phase C Wave 5: emits canonical `_meta:` envelope (B-tier promotion).
public struct ListFoldersHandler: Sendable {
public let store: NoteStore

Expand All @@ -15,10 +17,25 @@ public struct ListFoldersHandler: Sendable {
return Int64(i)
}()

let t0 = ContinuousClock.now
do {
let folders = try await store.listFolders(accountID: accountID)
let payload = ListFoldersResponse(folders: folders)
return makeResult(payload: payload)
let elapsed = ContinuousClock.now - t0
var filteredBy: [String] = []
if let accountID {
filteredBy.append("account_id=\(accountID)")
}
filteredBy.sort()
let meta = MetaEnvelope(
matchedTotal: folders.count,
returned: folders.count,
filteredBy: filteredBy,
latencyMs: elapsed.toMilliseconds()
)
return makeResultWithMeta(
payload: ListFoldersResponse(folders: folders),
meta: meta
)
} catch let error as NotesBladeError {
return errorResult(error)
} catch {
Expand Down
44 changes: 36 additions & 8 deletions Sources/AppleNotesBlade/Tools/ListNotes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Foundation
import MCP

/// Handler for `apple_notes_list_notes`. Index-only — never decodes bodies.
///
/// DD-338 Phase C Wave 5: emits canonical `_meta:` envelope (B-tier promotion).
/// `matched_total = returned` per Wave 5 OQ-4 ratification (post-LIMIT at v1).
/// `next_cursor` omitted for offset-paginated tools per OQ-6.
public struct ListNotesHandler: Sendable {
public let store: NoteStore

Expand All @@ -15,25 +19,49 @@ public struct ListNotesHandler: Sendable {
}
let folderID = Int64(folderRaw)

let since: Date? = {
let sinceRaw: String? = {
guard case .string(let raw) = arguments?["since"] else { return nil }
return parseISO8601(raw)
return raw
}()
let limit: Int = {
let since: Date? = sinceRaw.flatMap { parseISO8601($0) }
let limitArg: Int? = {
if case .int(let i) = arguments?["limit"] { return i }
return 100
return nil
}()
let offset: Int = {
let offsetArg: Int? = {
if case .int(let i) = arguments?["offset"] { return i }
return 0
return nil
}()
let limit = limitArg ?? 100
let offset = offsetArg ?? 0

let t0 = ContinuousClock.now
do {
let notes = try await store.listNotes(
folderID: folderID, since: since, limit: limit, offset: offset
)
let payload = ListNotesResponse(notes: notes)
return makeResult(payload: payload)
let elapsed = ContinuousClock.now - t0
var filteredBy: [String] = ["folder_id=\(folderID)"]
if let limitArg {
filteredBy.append("limit=\(limitArg)")
}
if let offsetArg {
filteredBy.append("offset=\(offsetArg)")
}
if let sinceRaw {
filteredBy.append("since=\(sinceRaw)")
}
filteredBy.sort()
let meta = MetaEnvelope(
matchedTotal: notes.count,
returned: notes.count,
filteredBy: filteredBy,
latencyMs: elapsed.toMilliseconds()
)
return makeResultWithMeta(
payload: ListNotesResponse(notes: notes),
meta: meta
)
} catch let error as NotesBladeError {
return errorResult(error)
} catch {
Expand Down
18 changes: 18 additions & 0 deletions Sources/AppleNotesBlade/Tools/ResultBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ func makeResult<T: Codable>(payload: T) -> CallTool.Result {
}
}

/// Wrap any `Codable` payload + a DD-338 `_meta:` envelope as a `CallTool.Result`.
/// The envelope is appended to the JSON payload via the canonical `\n\n` separator
/// per `appendMeta`. On encode failure falls through to `errorResult(.internalError(...))`
/// (no envelope on errors per Wave 3 OQ-7 ratification).
///
/// Use at every Track-promoted handler site to avoid per-handler boilerplate around
/// encode + envelope-append. See `MetaEnvelope.swift` for the helper module.
func makeResultWithMeta<T: Codable>(payload: T, meta: MetaEnvelope) -> CallTool.Result {
do {
let data = try makeToolEncoder().encode(payload)
let json = String(data: data, encoding: .utf8) ?? "{}"
let text = appendMeta(json, formatMetaLine(meta))
return CallTool.Result(content: [.text(text: text, annotations: nil, _meta: nil)])
} catch {
return errorResult(.internalError("encode_failure"))
}
}

/// Wrap a `NotesBladeError` as an error result. The `error` shape is stable;
/// consumer-side skills can switch on `error.code`.
func errorResult(_ error: NotesBladeError) -> CallTool.Result {
Expand Down
41 changes: 36 additions & 5 deletions Sources/AppleNotesBlade/Tools/SearchNotes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import MCP
/// Handler for `apple_notes_search_notes`. v0.1.0 implementation: SQL `LIKE`
/// against `ZTITLE1` and `ZSNIPPET` — never opens body bytes. Real FTS via
/// Apple's `NoteStoreFTS.sqlite` companion file is deferred.
///
/// DD-338 Phase C Wave 5: emits canonical `_meta:` envelope (B-tier promotion).
/// `filtered_by` `query=` is SHA-256-12 hashed per privacy discipline (mirrors
/// apple-mail search + W3 OQ-6 for `cf_d1_query`).
public struct SearchNotesHandler: Sendable {
public let store: NoteStore

Expand All @@ -23,15 +27,18 @@ public struct SearchNotesHandler: Sendable {
if case .int(let i) = arguments?["folder_id"] { return Int64(i) }
return nil
}()
let since: Date? = {
if case .string(let raw) = arguments?["since"] { return parseISO8601(raw) }
let sinceRaw: String? = {
if case .string(let raw) = arguments?["since"] { return raw }
return nil
}()
let limit: Int = {
let since: Date? = sinceRaw.flatMap { parseISO8601($0) }
let limitArg: Int? = {
if case .int(let i) = arguments?["limit"] { return i }
return 50
return nil
}()
let limit = limitArg ?? 50

let t0 = ContinuousClock.now
do {
let results = try await store.searchNotes(
query: query,
Expand All @@ -40,7 +47,31 @@ public struct SearchNotesHandler: Sendable {
since: since,
limit: limit
)
return makeResult(payload: SearchNotesResponse(query: query, results: results))
let elapsed = ContinuousClock.now - t0
var filteredBy: [String] = ["query=\(metaQueryDigest(query))"]
if let accountID {
filteredBy.append("account_id=\(accountID)")
}
if let folderID {
filteredBy.append("folder_id=\(folderID)")
}
if let sinceRaw {
filteredBy.append("since=\(sinceRaw)")
}
if let limitArg {
filteredBy.append("limit=\(limitArg)")
}
filteredBy.sort()
let meta = MetaEnvelope(
matchedTotal: results.count,
returned: results.count,
filteredBy: filteredBy,
latencyMs: elapsed.toMilliseconds()
)
return makeResultWithMeta(
payload: SearchNotesResponse(query: query, results: results),
meta: meta
)
} catch let error as NotesBladeError {
return errorResult(error)
} catch {
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppleNotesBlade/Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ extension AppleNotesBlade {
/// SemVer string for the library. Bump together with the git tag.
/// Pre-release suffixes (`-rc1`) are allowed; consumers should pin by SHA
/// not by version while the suffix is non-empty.
public static let semver = "0.1.0"
public static let semver = "0.2.0"
}
Loading
Loading