mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
M9 #4.2: project-scoped chat + shared SFTP parity services
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:
1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
spawn hermes acp with cwd = project.path, record the attribution.
Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):
- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
ScarfCore. Public struct. Mac consumer unchanged (imports it via
ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
Was already transport-backed, so the port is straight lift-and-
shift: made public, added #if canImport(os) guards around the
Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
marker-splice logic for the Scarf-managed region of AGENTS.md:
- applyBlock(_:to:) — pure text splice with 3-case handling.
- writeBlock(_:forProjectAt:context:) — transport-backed write.
- renderMinimalBlock(projectName:projectPath:) — iOS-side block
composer (no template-manifest or cron-attribution fields — iOS
doesn't yet surface those concepts; markers + identity headers
match Mac output byte-for-byte so a project scaffolded on iOS
round-trips cleanly through the Mac).
Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.
ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
In project…). Loads the project list over SFTP via
ProjectDashboardService (already transport-backed, works on iOS).
Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
session, writes the minimal context block to <project>/AGENTS.md
over SFTP, spawns hermes acp with cwd = project.path, records the
attribution via SessionAttributionService. Non-fatal on block-
write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
projectPath + projectName, so the regular start() and the new
project path share one ACP setup path. Attribution write happens
after newSession returns and the sessionId is known.
Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
|
||||
/// project path a chat was started for. Written on session create
|
||||
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
|
||||
/// by the per-project Sessions tab.
|
||||
///
|
||||
/// Hermes's own `state.db` has no `cwd` column on the sessions
|
||||
/// table — the cwd is passed at runtime via ACP but not persisted
|
||||
/// on its side. This sidecar is how we recover the attribution
|
||||
/// without requiring an upstream schema change.
|
||||
///
|
||||
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
|
||||
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||
/// can prefer that and fall back to this file for pre-upgrade
|
||||
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||
///
|
||||
/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record
|
||||
/// type — ScarfGo's project-scoped chat writes here over SFTP.
|
||||
public struct SessionProjectMap: Codable, Sendable {
|
||||
public var mappings: [String: String]
|
||||
public var updatedAt: String?
|
||||
|
||||
public init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||
self.mappings = mappings
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Current time in ISO-8601 format, suitable for the
|
||||
/// `updatedAt` field. Matches the format used elsewhere in
|
||||
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||
/// greps across .json files sees consistent timestamps.
|
||||
public static func nowISO8601() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Pure block-splice logic for the Scarf-managed region of a project's
|
||||
/// `<project>/AGENTS.md`. Shared by Mac (which wraps it in
|
||||
/// `ProjectAgentContextService` with template-manifest + cron-aware
|
||||
/// block rendering) and ScarfGo (which renders a simpler block and
|
||||
/// writes it over SFTP).
|
||||
///
|
||||
/// The marker contract is a cross-platform invariant — both apps must
|
||||
/// produce byte-identical markers so a Mac-scaffolded block round-trips
|
||||
/// through iOS and vice-versa without either side treating the other's
|
||||
/// content as "missing markers."
|
||||
public enum ProjectContextBlock {
|
||||
|
||||
/// Load-bearing across releases. Do not change these strings
|
||||
/// without a coordinated migration — existing project AGENTS.md
|
||||
/// files on disk carry them.
|
||||
public static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
public static let endMarker = "<!-- scarf-project:end -->"
|
||||
|
||||
/// Errors surfaced by writers. Narrow set — most callers just log
|
||||
/// and continue; a missing project-context block is a polish
|
||||
/// degradation, not a chat-start blocker.
|
||||
public enum WriteError: Error, LocalizedError {
|
||||
case encodingFailed
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed: return "Couldn't encode AGENTS.md block as UTF-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splice `block` into `existing`, preserving everything outside
|
||||
/// the markers. Three cases:
|
||||
/// 1. `existing` has both markers → replace inclusive region.
|
||||
/// 2. `existing` has no markers → prepend block + blank line.
|
||||
/// 3. `existing` has only a begin marker → prepend (don't guess).
|
||||
public static func applyBlock(_ block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return block + "\n" }
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: trimmingRightNewlines(before) + "\n\n"
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + trimmingLeftNewlines(after)
|
||||
return prefix + block + suffix
|
||||
}
|
||||
|
||||
/// Read `<project>/AGENTS.md`, splice in the given block, write
|
||||
/// back — all via the provided context's transport. Idempotent on
|
||||
/// identical inputs.
|
||||
///
|
||||
/// Called by ScarfGo's ChatController.startNewSession when the
|
||||
/// user picks "In project…". Mac's ProjectAgentContextService is
|
||||
/// a richer wrapper that constructs the block first, but the
|
||||
/// persistence step uses the same splice logic under the hood.
|
||||
public static func writeBlock(
|
||||
_ block: String,
|
||||
forProjectAt projectPath: String,
|
||||
context: ServerContext
|
||||
) throws {
|
||||
let transport = context.makeTransport()
|
||||
let agentsMdPath = projectPath + "/AGENTS.md"
|
||||
|
||||
if !transport.fileExists(projectPath) {
|
||||
try transport.createDirectory(projectPath)
|
||||
}
|
||||
|
||||
if !transport.fileExists(agentsMdPath) {
|
||||
let data = (block + "\n").data(using: .utf8) ?? Data()
|
||||
try transport.writeFile(agentsMdPath, data: data)
|
||||
return
|
||||
}
|
||||
|
||||
let existingData = try transport.readFile(agentsMdPath)
|
||||
let existing = String(data: existingData, encoding: .utf8) ?? ""
|
||||
let rewritten = applyBlock(block, to: existing)
|
||||
guard let outData = rewritten.data(using: .utf8) else {
|
||||
throw WriteError.encodingFailed
|
||||
}
|
||||
guard outData != existingData else { return }
|
||||
try transport.writeFile(agentsMdPath, data: outData)
|
||||
}
|
||||
|
||||
/// Render a minimal Scarf-managed block for iOS ScarfGo usage.
|
||||
/// Omits the template-manifest + cron-job sections that the Mac
|
||||
/// service fills in — ScarfGo v1 doesn't surface those concepts
|
||||
/// yet. The marker + identity headers match the Mac output byte-
|
||||
/// for-byte where the content overlaps, so a project scaffolded
|
||||
/// on iOS round-trips cleanly through the Mac.
|
||||
public static func renderMinimalBlock(projectName: String, projectPath: String) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append(beginMarker)
|
||||
lines.append("## Scarf project context")
|
||||
lines.append("")
|
||||
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
|
||||
lines.append("")
|
||||
lines.append("You are operating inside a Scarf project named **\"\(projectName)\"**. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
|
||||
lines.append("")
|
||||
lines.append("- **Project directory:** `\(projectPath)`")
|
||||
lines.append("- **Dashboard:** `\(projectPath)/.scarf/dashboard.json`")
|
||||
lines.append("")
|
||||
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
|
||||
lines.append(endMarker)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func trimmingRightNewlines(_ s: String) -> String {
|
||||
var result = s
|
||||
while let last = result.last, last.isNewline {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func trimmingLeftNewlines(_ s: String) -> String {
|
||||
var result = s
|
||||
while let first = result.first, first.isNewline {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||
/// project paths. Promoted to ScarfCore in M9 #4.2 so ScarfGo can
|
||||
/// write project attributions over SFTP — the whole service is
|
||||
/// transport-based, so Mac and iOS share the same code path.
|
||||
///
|
||||
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||
/// `HermesPathSet.sessionProjectMap`).
|
||||
///
|
||||
/// Thread safety: all public methods are `nonisolated` and each
|
||||
/// performs a single read-modify-write cycle that's atomic on
|
||||
/// disk. Concurrent writers (two Scarf windows on the same
|
||||
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||
/// but the in-memory read in one window may lag until that window
|
||||
/// reloads.
|
||||
public struct SessionAttributionService: Sendable {
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
||||
#endif
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
public nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
/// Load the current sidecar contents. Missing file or unparseable
|
||||
/// JSON returns an empty map — the sidecar is a convenience
|
||||
/// index, not a source of truth for anything load-bearing.
|
||||
public nonisolated func load() -> SessionProjectMap {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return SessionProjectMap()
|
||||
}
|
||||
do {
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||
} catch {
|
||||
#if canImport(os)
|
||||
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||
#endif
|
||||
return SessionProjectMap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the project path a given session was attributed to.
|
||||
/// Returns nil for unattributed sessions.
|
||||
public nonisolated func projectPath(for sessionID: String) -> String? {
|
||||
load().mappings[sessionID]
|
||||
}
|
||||
|
||||
/// Reverse lookup: every session ID attributed to the given
|
||||
/// project path.
|
||||
public nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||
let map = load()
|
||||
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||
}
|
||||
|
||||
// MARK: - Write
|
||||
|
||||
/// Record that `sessionID` was created under the given project
|
||||
/// path. Idempotent.
|
||||
public nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||
var map = load()
|
||||
if map.mappings[sessionID] == projectPath {
|
||||
return
|
||||
}
|
||||
map.mappings[sessionID] = projectPath
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
/// Remove a mapping. Exposed for future "detach from project"
|
||||
/// UIs and tests; today's Mac + iOS call sites don't invoke it
|
||||
/// because Hermes owns session lifecycle.
|
||||
public nonisolated func forget(sessionID: String) {
|
||||
var map = load()
|
||||
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func persist(_ map: SessionProjectMap) {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
let dir = context.paths.scarfDir
|
||||
do {
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(map)
|
||||
try transport.writeFile(path, data: data)
|
||||
} catch {
|
||||
#if canImport(os)
|
||||
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user