eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// 🤖 The native mirror of the web's default-command rule (apps.js
4// defaultCommandFor + each app's `defaultCommand`/`composerPlaceholder` in
5// web/static/apps/*.js): in a personal app-thread with NO bot member, plain
6// composer text runs the app's default command instead of sending a chat
7// message nobody reads — typing "buy milk" into To-do just adds it. With a bot
8// present the rule steps aside (plain text is a real message for the LLM), and
9// slash text never falls through — a leading "/" always means command.
10public enum AppDefaultCommand {
11 public struct Spec: Equatable {
12 public let eventType: String // the app event plain text emits ("todo.add")
13 // The verbs that add IN this thread, verb-stripped: the local command
14 // first ("/add" — the drift gate pins it to todo.js defaultCommand),
15 // then the app's global alias ("/todo"), which the web also runs here.
16 public let verbs: [String]
17 public let placeholder: String // the composer's advertisement of the routing
18 public let desc: String // the local verb's typeahead one-liner ("Add a to-do")
19 // The bare-verb push-back, shown inline like the web's in-thread usage
20 // reply — a bare "/add" or "/remind" must get feedback, never silence.
21 public let usage: String
22 }
23
24 // One entry per app that declares a defaultCommand on the web — keep in
25 // sync with web/static/apps/*.js (like AppChatProjection's tables).
26 // Reminders' verb list is just its global /remind: the web made the global
27 // the default (its "what + when" IS the in-thread grammar), so there's no
28 // local twin to lead with.
29 static let specs: [String: Spec] = [
30 "todo": Spec(eventType: "todo.add", verbs: ["/add", "/todo"],
31 placeholder: "Add a to-do…", desc: "Add a to-do",
32 usage: "Usage: /add <task> — e.g. /add buy milk"),
33 "remind": Spec(eventType: "remind.add", verbs: ["/remind"],
34 placeholder: "Remind me… e.g. call mum in 2h", desc: "Set a reminder",
35 usage: remindWhenPrompt),
36 ]
37
38 // remind.js's "when?" push-back, shown when routed text has no parseable
39 // time — the same wording as the web's usage reply.
40 public static let remindWhenPrompt =
41 "When should I remind you? Try “call mum in 10 minutes”, “standup at 9:00”, or “renew passport | 2026-08-01”."
42
43 /// The default-command spec for a thread, or nil when the rule is off:
44 /// not an app-thread, no default declared, or a bot member present.
45 public static func spec(appID: String?, hasBot: Bool) -> Spec? {
46 guard let appID, !hasBot else { return nil }
47 return specs[appID]
48 }
49
50 /// The app-local commands the "/" typeahead lists inside `appID`'s own
51 /// thread — the native slice of the web's commandCatalog(threadId), where
52 /// local verbs lead and the app's global alias drops out as redundant.
53 /// Unlike spec(), NOT bot-gated: the verb works in the thread either way.
54 public static func localCommands(appID: String?) -> [(name: String, desc: String)] {
55 guard let appID, let spec = specs[appID], let verb = spec.verbs.first else { return [] }
56 return [(name: verb, desc: spec.desc)]
57 }
58
59 /// What the composer should do with a draft in a default-command thread.
60 public enum Routing: Equatable {
61 case event(String) // emit the app event carrying this text
62 // Consumed with nothing to emit (a bare verb): the caller keeps the
63 // draft and sends NOTHING — the native stand-in for the web's private
64 // usage reply, and never a dead chat bubble.
65 case swallow
66 case passthrough // not ours — the normal send path decides
67 }
68
69 /// Classify a draft. Plain text routes wholesale; one of the app's verbs
70 /// ("/add buy milk", or the global "/todo buy milk" exactly as the web runs
71 /// it in-thread) routes with the verb stripped; a bare verb is swallowed;
72 /// any other slash text passes through (a "/" always means command).
73 public static func routing(_ text: String, spec: Spec) -> Routing {
74 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
75 guard !trimmed.isEmpty else { return .passthrough }
76 if trimmed.first != "/" { return .event(trimmed) }
77 let lower = trimmed.lowercased()
78 for verb in spec.verbs {
79 if lower == verb { return .swallow } // bare verb — nothing to add
80 if lower.hasPrefix(verb + " ") {
81 let args = trimmed.dropFirst(verb.count).trimmingCharacters(in: .whitespaces)
82 return args.isEmpty ? .swallow : .event(args)
83 }
84 }
85 return .passthrough
86 }
87}
88
89extension ChatFeedModel {
90 /// The composer-facing outcome of routing a draft through a thread's
91 /// default command — the ONE shared entry point both native composers call
92 /// (NativeChatScreen on iOS, NativeFeedView on the Mac), so the platforms
93 /// can't drift on the contract.
94 public enum DefaultCommandOutcome: Equatable {
95 case sent // event emitted — clear the draft
96 case kept // consumed, nothing sent (bare verb, or a down socket) — keep the draft
97 case rejected(String) // consumed with a push-back to SHOW ("when should I remind you?") — keep the draft
98 case notHandled // not ours — continue to the normal send path
99 }
100
101 /// Async because Reminders schedules its fire on the server before
102 /// emitting (remind.js addReminder's order); To-do resolves immediately.
103 public func routeDefaultCommand(_ draft: String, spec: AppDefaultCommand.Spec) async -> DefaultCommandOutcome {
104 switch AppDefaultCommand.routing(draft, spec: spec) {
105 case .passthrough: return .notHandled
106 case .swallow:
107 // A bare verb gets the usage line, like the web's in-thread reply
108 // — silence would leave the platforms answering the same keystroke
109 // differently.
110 return .rejected(spec.usage)
111 case .event(let text):
112 if spec.eventType == "remind.add" {
113 // Timeless text is the app pushing back, not an error path —
114 // the composer shows the prompt and keeps the draft for fixing.
115 guard let p = ReminderParse.parse(text) else {
116 return .rejected(AppDefaultCommand.remindWhenPrompt)
117 }
118 return await addReminder(text: p.text, when: p.when) ? .sent : .kept
119 }
120 return sendAppEvent(type: spec.eventType, text: text) ? .sent : .kept
121 }
122 }
123}
124