feat(skills): SkillSnapshotService + 'What's New' pill (Phase 3.4)

Per-server snapshot of skill signatures so the Skills tab can show
"2 new, 4 updated since you last looked" — same pattern Hermes's
`hermes skills update` CLI shows on the host.

ScarfCore SkillSnapshotService:
- [skillId: signature] map, signature is `<fileCount>:<sorted-files>`.
  New / removed / files-changed all show up as a delta.
- diff(against:) returns SkillSnapshotDiff with counts + a label
  string for the pill.
- markSeen(_:) persists the current set.
- Backend abstraction: file-based on Mac, UserDefaults on iOS,
  in-memory for tests.
- previousSnapshotEmpty silently primes first-load so users don't
  see "everything is new!" noise.

Mac SkillsView:
- whatsNewPill(diff:) tinted pill at the top with "Mark as seen".
- recomputeSnapshotDiff() on .task and on totalSkillCount change.

iOS SkillsListView:
- Same pill rendered as a Section row with "Seen" button.
- Recompute on .task + .refreshable.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:22:38 +02:00
parent 5c08c09dde
commit 751c9e6778
3 changed files with 373 additions and 2 deletions
@@ -0,0 +1,267 @@
import Foundation
#if canImport(os)
import os
#endif
/// Tracks "what skills did this Scarf instance see last time on this
/// server" so the Skills tab can render a "2 new, 4 updated since you
/// last looked" pill same pattern Hermes's `hermes skills update`
/// shows in the CLI.
///
/// **Storage shape.** Per server. Mac persists to a JSON file under
/// `~/Library/Application Support/com.scarf/skill-snapshots/<serverID>.json`;
/// iOS persists to `UserDefaults` under
/// `com.scarf.ios.skill-snapshot.<serverID>`. Both stores hold a
/// `[skillId: SkillSignature]` map. `skillId` is `<category>/<name>`
/// from the loader; `SkillSignature` is a compact hash of file count
/// + sorted file names so a skill update (added/removed/renamed file)
/// shows up as a delta even if Hermes-version-pinning isn't in play.
///
/// **Failure model.** Persist errors log + no-op (the diff degrades
/// to "everything looks new" but the user can still mark seen). Read
/// errors return an empty snapshot so the diff treats every skill as
/// new annoying once but recoverable.
public struct SkillSnapshotService: Sendable {
#if canImport(os)
static let logger = Logger(
subsystem: "com.scarf",
category: "SkillSnapshotService"
)
#endif
public let serverID: ServerID
private let backend: SnapshotBackend
public init(serverID: ServerID) {
self.serverID = serverID
#if os(macOS)
self.backend = .file(MacSnapshotBackend())
#else
self.backend = .userDefaults(IOSSnapshotBackend())
#endif
}
/// Public for tests. Production callers use the no-arg
/// init that picks the right backend per platform.
public init(serverID: ServerID, backend: SnapshotBackend) {
self.serverID = serverID
self.backend = backend
}
// MARK: - Public API
/// Compute the delta between the current skill set and the
/// last snapshot. Returns counts only the caller renders them
/// in a pill ("2 new, 4 updated since you last looked").
public func diff(against current: [HermesSkill]) -> SkillSnapshotDiff {
let last = backend.read(for: serverID)
let currentSigs = Self.signatures(for: current)
var newCount = 0
var updatedCount = 0
for (id, sig) in currentSigs {
if let prev = last[id] {
if prev != sig { updatedCount += 1 }
} else {
newCount += 1
}
}
return SkillSnapshotDiff(
newCount: newCount,
updatedCount: updatedCount,
previousSnapshotEmpty: last.isEmpty,
changedSkillIds: Set(currentSigs.compactMap { id, sig in
let prev = last[id]
if prev == nil { return id }
return prev != sig ? id : nil
})
)
}
/// Record the current skills as seen. Called on user action
/// ("Mark all as seen") or on the first-ever load (when
/// `previousSnapshotEmpty` is true and we don't want to show
/// every skill as new on next launch).
public func markSeen(_ current: [HermesSkill]) {
backend.write(Self.signatures(for: current), for: serverID)
}
// MARK: - Private
/// Compute a stable per-skill signature: `<fileCount>:<joined-files>`.
/// Hash via Foundation's `String.hashValue` would be unstable
/// across launches, so we keep the raw concatenation; payload is
/// small enough that storage size doesn't matter.
static func signatures(for skills: [HermesSkill]) -> [String: SkillSignature] {
var result: [String: SkillSignature] = [:]
for skill in skills {
let files = skill.files.sorted().joined(separator: "|")
result[skill.id] = "\(skill.files.count):\(files)"
}
return result
}
}
public typealias SkillSignature = String
/// Result of comparing the current skills to the last snapshot.
public struct SkillSnapshotDiff: Sendable, Equatable {
public let newCount: Int
public let updatedCount: Int
/// True when this is the first time we've ever seen the skills
/// for this server (no prior snapshot on disk). The view should
/// silently mark everything as seen rather than rendering a
/// "5 new!" pill on a fresh install.
public let previousSnapshotEmpty: Bool
/// Skill ids (`<category>/<name>`) that count as new or updated.
/// Used by the Mac UI to filter the tree to only changed entries
/// when the user taps the pill.
public let changedSkillIds: Set<String>
public var hasChanges: Bool { newCount + updatedCount > 0 }
public init(
newCount: Int,
updatedCount: Int,
previousSnapshotEmpty: Bool,
changedSkillIds: Set<String>
) {
self.newCount = newCount
self.updatedCount = updatedCount
self.previousSnapshotEmpty = previousSnapshotEmpty
self.changedSkillIds = changedSkillIds
}
/// Compact label for the "What's New" pill, e.g.
/// "2 new, 4 updated since you last looked" or "1 new skill".
public var label: String {
switch (newCount, updatedCount) {
case (let n, 0): return n == 1 ? "1 new skill since you last looked" : "\(n) new skills since you last looked"
case (0, let u): return u == 1 ? "1 updated skill since you last looked" : "\(u) updated skills since you last looked"
default: return "\(newCount) new, \(updatedCount) updated since you last looked"
}
}
}
// MARK: - Backend abstraction
/// Per-platform persistence for the skill-snapshot store. Both
/// implementations encode `[skillId: signature]` as JSON and key by
/// `ServerID`. Tests use the in-memory variant.
public enum SnapshotBackend: Sendable {
case file(MacSnapshotBackend)
case userDefaults(IOSSnapshotBackend)
case inMemory(InMemorySnapshotBackend)
func read(for serverID: ServerID) -> [String: SkillSignature] {
switch self {
case .file(let b): return b.read(for: serverID)
case .userDefaults(let b): return b.read(for: serverID)
case .inMemory(let b): return b.read(for: serverID)
}
}
func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {
switch self {
case .file(let b): b.write(snapshot, for: serverID)
case .userDefaults(let b): b.write(snapshot, for: serverID)
case .inMemory(let b): b.write(snapshot, for: serverID)
}
}
}
#if os(macOS)
/// Mac persistence: one JSON file per server under
/// `~/Library/Application Support/com.scarf/skill-snapshots/`.
public struct MacSnapshotBackend: Sendable {
public init() {}
public func read(for serverID: ServerID) -> [String: SkillSignature] {
guard let url = Self.fileURL(for: serverID),
let data = try? Data(contentsOf: url),
let map = try? JSONDecoder().decode([String: SkillSignature].self, from: data)
else { return [:] }
return map
}
public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {
guard let url = Self.fileURL(for: serverID) else { return }
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(snapshot)
try data.write(to: url, options: .atomic)
} catch {
#if canImport(os)
SkillSnapshotService.logger.warning(
"couldn't persist skill snapshot for \(serverID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
#endif
}
}
private static func fileURL(for serverID: ServerID) -> URL? {
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else { return nil }
return appSupport
.appendingPathComponent("com.scarf", isDirectory: true)
.appendingPathComponent("skill-snapshots", isDirectory: true)
.appendingPathComponent("\(serverID.uuidString).json")
}
}
#else
public struct MacSnapshotBackend: Sendable {
public init() {}
public func read(for serverID: ServerID) -> [String: SkillSignature] { [:] }
public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {}
}
#endif
/// iOS persistence: one UserDefaults key per server.
/// `@unchecked Sendable` because `UserDefaults` itself doesn't conform
/// it is in fact thread-safe per Apple docs, the conformance just
/// hasn't been added to the headers.
public final class IOSSnapshotBackend: @unchecked Sendable {
public static let keyPrefix = "com.scarf.ios.skill-snapshot."
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
public func read(for serverID: ServerID) -> [String: SkillSignature] {
let key = Self.keyPrefix + serverID.uuidString
guard let data = defaults.data(forKey: key),
let map = try? JSONDecoder().decode([String: SkillSignature].self, from: data)
else { return [:] }
return map
}
public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {
let key = Self.keyPrefix + serverID.uuidString
guard let data = try? JSONEncoder().encode(snapshot) else { return }
defaults.set(data, forKey: key)
}
}
/// In-memory backend for tests. Per-instance map; not shared.
public final class InMemorySnapshotBackend: @unchecked Sendable {
private var store: [ServerID: [String: SkillSignature]] = [:]
public init() {}
public func read(for serverID: ServerID) -> [String: SkillSignature] {
store[serverID] ?? [:]
}
public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {
store[serverID] = snapshot
}
}
+46 -2
View File
@@ -8,6 +8,7 @@ struct SkillsListView: View {
let config: IOSServerConfig let config: IOSServerConfig
@State private var vm: IOSSkillsViewModel @State private var vm: IOSSkillsViewModel
@State private var snapshotDiff: SkillSnapshotDiff?
private static let sharedContextID: ServerID = ServerID( private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1" uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -21,6 +22,28 @@ struct SkillsListView: View {
var body: some View { var body: some View {
List { List {
if let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
Section {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(diff.label)
.font(.callout)
}
Spacer()
Button("Seen") {
SkillSnapshotService(serverID: Self.sharedContextID)
.markSeen(vm.categories.flatMap(\.skills))
snapshotDiff = nil
}
.controlSize(.small)
.buttonStyle(.bordered)
}
}
}
if let err = vm.lastError { if let err = vm.lastError {
Section { Section {
Label(err, systemImage: "exclamationmark.triangle.fill") Label(err, systemImage: "exclamationmark.triangle.fill")
@@ -71,8 +94,29 @@ struct SkillsListView: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
} }
} }
.refreshable { await vm.load() } .refreshable {
.task { await vm.load() } await vm.load()
recomputeSnapshotDiff()
}
.task {
await vm.load()
recomputeSnapshotDiff()
}
}
/// v2.5 "What's New" diff against the last-seen snapshot for this
/// server. First-time users get a silent prime the pill only
/// renders on subsequent loads when something actually changed.
private func recomputeSnapshotDiff() {
let allSkills = vm.categories.flatMap(\.skills)
let svc = SkillSnapshotService(serverID: Self.sharedContextID)
let diff = svc.diff(against: allSkills)
if diff.previousSnapshotEmpty {
svc.markSeen(allSkills)
snapshotDiff = nil
} else {
snapshotDiff = diff
}
} }
} }
@@ -9,6 +9,10 @@ struct SkillsView: View {
/// is in flight; populated with `.present` / `.missing(...)` / /// is in flight; populated with `.present` / `.missing(...)` /
/// `.unknown(...)` on completion. /// `.unknown(...)` on completion.
@State private var designMdNpxStatus: SkillPrereqService.Status? @State private var designMdNpxStatus: SkillPrereqService.Status?
/// Diff between the current skill list and the last-seen snapshot
/// for the active server. Drives the v2.5 "What's New" pill at
/// the top of the Skills list. Nil before first compute.
@State private var snapshotDiff: SkillSnapshotDiff?
@Environment(\.serverContext) private var serverContext @Environment(\.serverContext) private var serverContext
@State private var currentTab: Tab = .installed @State private var currentTab: Tab = .installed
@@ -35,6 +39,15 @@ struct SkillsView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
modePicker modePicker
// v2.5 "What's New" pill only renders when the diff has
// changes against a non-empty prior snapshot (first launch
// is silent so users aren't drowned in "everything is
// new!" noise).
if let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
whatsNewPill(diff: diff)
}
Divider() Divider()
switch currentTab { switch currentTab {
case .installed: installedContent case .installed: installedContent
@@ -58,6 +71,53 @@ struct SkillsView: View {
designMdNpxStatus = await svc.probe(binary: "npx") designMdNpxStatus = await svc.probe(binary: "npx")
} }
} }
// Snapshot diff: recompute whenever the loaded skill list
// changes. First-load with no prior snapshot silently primes
// the snapshot subsequent changes show the pill.
.onChange(of: viewModel.totalSkillCount) { _, _ in
recomputeSnapshotDiff()
}
.task {
recomputeSnapshotDiff()
}
}
/// Compute the snapshot diff against the active server's last-seen
/// state. On a first-ever load (empty snapshot) we silently mark
/// the current set as seen so the next load shows real deltas.
private func recomputeSnapshotDiff() {
let allSkills = viewModel.categories.flatMap(\.skills)
let svc = SkillSnapshotService(serverID: serverContext.id)
let diff = svc.diff(against: allSkills)
if diff.previousSnapshotEmpty {
// Silent prime don't show the pill; just record what
// we've seen so future diffs are honest.
svc.markSeen(allSkills)
snapshotDiff = nil
} else {
snapshotDiff = diff
}
}
/// Tappable pill rendering "2 new, 4 updated since you last looked".
/// Tap mark current set as seen + dismiss the pill.
private func whatsNewPill(diff: SkillSnapshotDiff) -> some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
Text(diff.label)
.font(.callout)
Spacer()
Button("Mark as seen") {
let svc = SkillSnapshotService(serverID: serverContext.id)
svc.markSeen(viewModel.categories.flatMap(\.skills))
snapshotDiff = nil
}
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.tint.opacity(0.1))
} }
private var modePicker: some View { private var modePicker: some View {