mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user