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
4 changes: 4 additions & 0 deletions GraphcodeKit/Sources/Domain/GraphImport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ public enum GraphImportPlanner {
heartbeatIntervalSeconds: node.heartbeatIntervalSeconds,
firstInstruction: node.firstInstruction,
pausesBeforeWritesOnly: node.pausesBeforeWritesOnly,
// Dropped for the same reason the worktree binding is, and one more: an
// attachment's file lives under the *exporting* node's id, which the remap above
// has just changed. Both halves of the path would be wrong.
attachments: [],
goal: node.goal,
backend: node.backend,
modelTier: node.modelTier,
Expand Down
41 changes: 33 additions & 8 deletions GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
/// it agreed to — and so the prompt stays derivable from the node instead of being a
/// sentence nobody can re-read.
public var pausesBeforeWritesOnly: Bool
/// Images attached to whichever field holds this loop's brief (`PromptAttachment`).
/// Their paths replace the `[image #N]` placeholders in `sessionPrompt`.
public var attachments: [PromptAttachment]
/// The stop condition a goal-based node was handed (`.goalBased`) — see
/// docs/01-loop-taxonomy.md#goal-based--you-hand-off-the-stop-condition.
public var goal: GoalSpec?
Expand Down Expand Up @@ -217,6 +220,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
heartbeatIntervalSeconds: Double? = nil,
firstInstruction: String? = nil,
pausesBeforeWritesOnly: Bool = false,
attachments: [PromptAttachment] = [],
goal: GoalSpec? = nil,
backend: CLISessionBackendKind = .claudeCode,
modelTier: ModelTier? = nil,
Expand Down Expand Up @@ -246,6 +250,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
self.heartbeatIntervalSeconds = heartbeatIntervalSeconds
self.firstInstruction = firstInstruction
self.pausesBeforeWritesOnly = pausesBeforeWritesOnly
self.attachments = attachments
self.goal = goal
self.backend = backend
self.modelTier = modelTier
Expand Down Expand Up @@ -313,10 +318,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
switch loopType {
case .sketch:
// The starting note, when there is one. A blank note means the session opens
// quiet and waits — asking nothing up front is what the type is for.
let note = firstInstruction?.trimmingCharacters(in: .whitespaces) ?? ""
// quiet and waits — asking nothing up front is what the type is for. An attached
// image is itself something to say, so a note that is only a picture still opens.
let note =
PromptAttachments.resolving(firstInstruction, attachments: attachments)?
.trimmingCharacters(in: .whitespaces) ?? ""
return note.isEmpty ? nil : note
case .timeBased:
// Placeholders are swapped for paths before anything reads the prompt as a
// directive: `/loop <interval> <task>` takes the rest of the line as the task, so
// a path inside it travels into every scheduled pass.
let triggerPrompt = PromptAttachments.resolving(
self.triggerPrompt, attachments: attachments)
// Copilot's `/every` submits its first prompt only after the interval elapses, so
// a directive-led opening armed correctly and then sat idle — and the typed
// first-pass workaround raced the composer. The reliable channel is the opening
Expand All @@ -335,7 +348,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
interval.isFinite,
interval > 0
{
let task = heartbeatTask ?? ""
// `heartbeatTask` reads the stored prompt, so it needs resolving of its own —
// the shadowed local above does not reach inside it.
let task = PromptAttachments.resolving(heartbeatTask, attachments: attachments) ?? ""
return "Run one pass of this task now: \(task) Then stay in the session — every "
+ "\(Int(interval))s you will receive a [graphcode] heartbeat message, and each "
+ "one is your cue to run the next pass. Do not schedule your own /loop, wakeup, "
Expand All @@ -352,16 +367,22 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
+ "\(task) Do not schedule your own /loop, wakeup, or cron for it — the "
+ "orchestrator holds the timer. Stay in the session between heartbeats."
case .goalBased:
guard let prompt = goal?.sessionPrompt(directive: backend.capabilities.goalDirective)
else { return nil }
// Resolved on the summary rather than on the composed prompt: `/goal` takes the
// rest of the line as its condition, and a path appended past the predicate and
// the metric would become part of what an evaluator judges.
guard var goal else { return nil }
goal.summary =
PromptAttachments.resolving(goal.summary, attachments: attachments) ?? goal.summary
let prompt = goal.sessionPrompt(directive: backend.capabilities.goalDirective)
// A backend whose verdict the daemon cannot read resolves a goal with no predicate
// only when its session reports it met. The briefing says so, but a session follows
// its prompt first: OpenCode and pi loops finished their work and never reported.
guard !backend.recordsGoalVerdict, goal?.effectivePredicate == nil else { return prompt }
guard !backend.recordsGoalVerdict, goal.effectivePredicate == nil else { return prompt }
return prompt + " " + Self.reportDoneSentence
case .turnBased:
return Self.turnBasedPrompt(
instruction: firstInstruction, check: checkDescription,
instruction: PromptAttachments.resolving(firstInstruction, attachments: attachments),
check: checkDescription,
beforeWritesOnly: pausesBeforeWritesOnly)
case .composite: return nil
}
Expand Down Expand Up @@ -564,7 +585,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy
case lastMailroomRead, mailroomWatch
case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly
case summary, board, heartbeatIntervalSeconds, stallReason
case summary, board, heartbeatIntervalSeconds, stallReason, attachments
case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure, resolution
case pendingCompletion, goalSetAt
}
Expand All @@ -586,6 +607,10 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
// turn, which is what `false` says.
pausesBeforeWritesOnly =
try container.decodeIfPresent(Bool.self, forKey: .pausesBeforeWritesOnly) ?? false
// Absent from graphs saved before attachments existed, which is what an empty
// list says.
attachments =
try container.decodeIfPresent([PromptAttachment].self, forKey: .attachments) ?? []
goal = try container.decodeIfPresent(GoalSpec.self, forKey: .goal)
backend =
try container.decodeIfPresent(CLISessionBackendKind.self, forKey: .backend) ?? .claudeCode
Expand Down
12 changes: 11 additions & 1 deletion GraphcodeKit/Sources/Domain/NodeDraft.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ public struct NodeDraft: Codable, Equatable, Sendable {
///
/// `nil` for anything a human created, which is the truth: the form is not a loop.
public var createdBy: UUID?
/// Images the human attached to the brief — see `PromptAttachment`. The bytes are
/// already on disk by the time a draft carries one; this is only where they are.
public var attachments: [PromptAttachment]
/// Which template the brief came from — attribution, carried to the node.
public var createdFromTemplateID: UUID?
/// The template a **timed or composite** draft follows — see
Expand All @@ -90,6 +93,7 @@ public struct NodeDraft: Codable, Equatable, Sendable {
worktree: WorktreeRef? = nil,
subGraph: LoopGraph? = nil,
createdBy: UUID? = nil,
attachments: [PromptAttachment] = [],
createdFromTemplateID: UUID? = nil,
templateFollow: TemplateFollow? = nil
) {
Expand All @@ -107,6 +111,7 @@ public struct NodeDraft: Codable, Equatable, Sendable {
self.worktree = worktree
self.subGraph = subGraph
self.createdBy = createdBy
self.attachments = attachments
self.createdFromTemplateID = createdFromTemplateID
self.templateFollow = templateFollow
}
Expand Down Expand Up @@ -202,6 +207,7 @@ public struct NodeDraft: Codable, Equatable, Sendable {
heartbeatIntervalSeconds: heartbeatIntervalSeconds,
firstInstruction: firstInstruction,
pausesBeforeWritesOnly: pausesBeforeWritesOnly,
attachments: attachments,
goal: goal,
backend: effectiveBackend,
modelTier: modelTier,
Expand All @@ -225,7 +231,7 @@ extension NodeDraft {
private enum CodingKeys: String, CodingKey {
case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier
case worktree, subGraph, createdBy, firstInstruction, pausesBeforeWritesOnly
case heartbeatIntervalSeconds
case heartbeatIntervalSeconds, attachments
case createdFromTemplateID, templateFollow
}

Expand Down Expand Up @@ -253,6 +259,10 @@ extension NodeDraft {
worktree = try container.decodeIfPresent(WorktreeRef.self, forKey: .worktree)
subGraph = try container.decodeIfPresent(LoopGraph.self, forKey: .subGraph)
createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy)
// Absent from every draft a CLI that predates attachments sends, which is what an
// empty list says.
attachments =
try container.decodeIfPresent([PromptAttachment].self, forKey: .attachments) ?? []
createdFromTemplateID =
try container.decodeIfPresent(UUID.self, forKey: .createdFromTemplateID)
templateFollow =
Expand Down
93 changes: 93 additions & 0 deletions GraphcodeKit/Sources/Domain/PromptAttachment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

/// An image a human dropped into the New Node dialog, and where it landed on disk.
///
/// **The image itself can never travel.** `zmx` starts a session by *typing* its launch
/// command into a PTY (`SessionBriefing`), so everything a loop opens with is text on a
/// line — a canonical-mode tty at that, which drops whatever runs past `MAX_CANON`. What
/// does travel is the path, and every backend graphcode drives can open one: `codex` and
/// `pi` have a flag for it, and the rest read the file with their own tools once the
/// prompt names it.
///
/// So the form writes the bytes down once, beside the node's memory
/// (`NodeMemory.attachmentsDirectory`), and the prompt carries the path. The node id is
/// chosen by the client (`NodeDraft.id`), which is what makes that possible before the
/// node exists.
///
/// Local graphs only. A remote project's session runs on another machine, and the ensure
/// dial that delivers graphcode's files there carries text (`remoteDeliveryScript`) — a
/// path to a file that host has never seen would read to the agent as a missing file.
public struct PromptAttachment: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
/// Absolute, on the machine that runs the loop.
public var path: String

public init(id: UUID = UUID(), path: String) {
self.id = id
self.path = path
}

public var fileName: String { URL(fileURLWithPath: path).lastPathComponent }
}

/// How an attachment's path gets into the sentence a human wrote.
///
/// The human never types or sees a path: `[image #1]` stands in its place in the field,
/// and the token is swapped for the path when the prompt is composed
/// (`LoopNode.sessionPrompt`). That keeps the picture where the sentence wanted it —
/// "compare `[image #1]` with the current header" — rather than in a list at the end
/// that the agent has to guess the intent of.
public enum PromptAttachments {
/// The placeholder for the `number`-th attachment, 1-based. ASCII and unmistakable:
/// it has to survive a round trip through a text field, argv, and a typed command
/// line, and it must not collide with anything a person would write by hand.
public static func token(_ number: Int) -> String { "[image #\(number)]" }

/// `text` with every `[image #N]` replaced by the N-th attachment's path, and any
/// attachment the text never named stated at the end.
///
/// The trailer keeps plain words on both sides of every path, for the reason
/// `NodeMemory.promptPointer` does: this string rides argv, `zmx`'s typed command
/// line and sometimes ssh, and punctuation touching a path has eaten a file extension
/// before.
public static func resolving(
_ text: String?, attachments: [PromptAttachment]
) -> String? {
guard !attachments.isEmpty else { return text }
var resolved = text ?? ""
var unnamed: [String] = []
for (offset, attachment) in attachments.enumerated() {
let placeholder = token(offset + 1)
if resolved.contains(placeholder) {
resolved = resolved.replacingOccurrences(of: placeholder, with: attachment.path)
} else {
unnamed.append(attachment.path)
}
}
guard !unnamed.isEmpty else { return resolved }
let trailer =
unnamed.count == 1
? "An image for this task is at \(unnamed[0]) - open it before you start."
: "Images for this task are at \(unnamed.joined(separator: " and ")) "
+ "- open them before you start."
let body = resolved.trimmingCharacters(in: .whitespacesAndNewlines)
return body.isEmpty ? trailer : body + " " + trailer
}

/// `text` with the `number`-th placeholder dropped and every later one renumbered, so
/// removing the middle chip of three doesn't leave `[image #3]` pointing at nothing.
public static func removing(attachment number: Int, from text: String, of count: Int)
-> String
{
var result = text.replacingOccurrences(of: token(number), with: "")
var later = number + 1
while later <= count {
result = result.replacingOccurrences(of: token(later), with: token(later - 1))
later += 1
}
while result.contains(" ") {
result = result.replacingOccurrences(of: " ", with: " ")
}
return result.trimmingCharacters(in: .whitespaces)
}
}
14 changes: 14 additions & 0 deletions GraphcodeKit/Sources/Sessions/NodeMemory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ public enum NodeMemory {
.appendingPathComponent(nodeID.uuidString, isDirectory: true)
}

/// Where images attached to a node's brief are kept (`PromptAttachment`).
///
/// Inside the node's memory directory deliberately: `remove` already wipes that when
/// the node is deleted, so an attachment cannot outlive the loop it was for, and a
/// path-verifying backend is granted one directory rather than two.
public static func attachmentsDirectory(
forProjectPath projectPath: String, nodeID: UUID, baseURL: URL = SupportDirectory.url
) -> URL {
directory(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL)
.appendingPathComponent(attachmentsDirectoryName, isDirectory: true)
}

public static let attachmentsDirectoryName = "attachments"

public static func logURL(
forProjectPath projectPath: String, nodeID: UUID, baseURL: URL = SupportDirectory.url
) -> URL {
Expand Down
8 changes: 8 additions & 0 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,14 @@ public enum ZmxSessionLauncher {
}
}
if let worktree = node.worktreeBinding?.worktreePath { paths.append(worktree) }
// Codex and Copilot verify paths, and a prompt naming an image the session is denied
// reads as the agent ignoring its instructions — the same failure the briefing's
// `--add-dir` exists to prevent. Granted from the paths themselves rather than from
// the memory directory, so an attachment that came from somewhere else still works.
for attachment in node.attachments {
let directory = URL(fileURLWithPath: attachment.path).deletingLastPathComponent().path
if !paths.contains(directory) { paths.append(directory) }
}
return paths
}

Expand Down
Loading
Loading