Repository navigation
Expand file tree
/
Copy pathtools_standing.go
More file actions
1599 lines (1526 loc) · 78.3 KB
/
Copy pathtools_standing.go
File metadata and controls
1599 lines (1526 loc) · 78.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package session
// The `stand` tool: how a sentence in a conversation becomes something that
// keeps working after the window is closed.
//
// A person says "remind me at six", "tell me when CI goes red", "every Monday
// draft the weekly update", "keep main green", "tonight run the full suite".
// Every one of those is an ordinary sentence and NONE of them is a command:
// there is no cron syntax to learn and no scheduler screen to open, so the
// whole recognition problem lands on the model, and this tool is the one verb
// it reaches for when it recognises one. docs/AMBIENT.md is the design; the
// object it makes is internal/standing's [standing.Item].
//
// ── THE LAWS THIS FILE APPLIES ──
//
// - NOTHING STANDS UNTIL THE PERSON SAYS YES. The tool proposes; the card is
// answered; only then is anything created. There is no argument, no phrasing
// and no op that arms an item without a yes.
//
// - NO CLOCK WHILE SOMEBODY IS THERE. The card is a thing a person reads:
// their own sentence, when it would wake, what it would cost. Reading that
// takes as long as it takes, and a card that ended itself halfway through
// was a card the person watched expire rather than answered. So a WATCHED
// session sends NO deadline at all (Notice.Deadline stays zero, and the
// surface draws no meter for a zero) and the wait ends on exactly three
// things: they answer, the turn is interrupted, or the session closes.
//
// - AND SILENCE STILL ARMS NOTHING. This is where the standing card parts
// company with propose_task's, whose silence is a yes (task.go). A task is
// bounded work somebody is watching; a standing item spends money on its
// own, forever, at times nobody chose. A turn that ended with the card
// unanswered therefore leaves NOTHING behind, and the model is told exactly
// that rather than a refusal nobody made.
//
// - AN UNWATCHED SESSION CANNOT RATIFY ONE AT ALL. A --once run, a task node,
// a firing's own headless session: none of them has anybody to answer, and a
// card drawn into an empty room would be a card only a clock could ever
// answer. So the tool refuses in plain words rather than proposing there.
// (The door does not even fill Config.Standing for those, so in practice the
// tool is absent — this is the belt-and-braces half of the same law.)
//
// - THE OFFER TO KEEP CHECKING WITH NO WINDOW OPEN IS MADE ONCE, EVER, and
// the yes is journaled BEFORE the host is touched. A marker file under the
// store root is the whole memory of it: asked, what they said, when. An
// install that half-worked must never be an install nobody remembers asking
// about.
//
// The tool is ABSENT when Config.Standing is nil (tools.go), which is the
// absence law this codebase is built on: a verb with nothing behind it is worse
// than no verb, because a model told it can set up a reminder will plan a whole
// reply around one.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/exec/bare"
"github.com/Agent-Field/codeaf/internal/standing"
)
// The rails a proposal takes when the model names none. ONE POOL, NOT N KNOBS:
// the person's real protection is the machine-wide daily allowance plus the
// audit gate, so these per-item rails are a quiet backstop, not a negotiation.
const (
// standDefaultPerRunUSD is what one firing may spend, probe and judgment
// included. It is [standing.DefaultPerRunUSD] and not a figure of its own,
// because the schema this file shows the model, the charter the store
// creates and the price the proposal quotes have to be one number.
standDefaultPerRunUSD = standing.DefaultPerRunUSD
// standDefaultMaxPerDay is how many times an item may fire in a local
// day.
standDefaultMaxPerDay = 10
)
// standingPastGrace is how far behind the clock a named moment may be and still
// be taken as meant for now.
//
// A REMINDER CAN NEVER BE SET FOR A MOMENT THAT HAS PASSED, which is the law
// [standingAtMoment] enforces; this is the only slack in it. A model works its
// stamp out from the `Now` line and calls a beat later, so a moment landing a
// few seconds behind the clock is arithmetic that was right when it was done —
// firing that immediately is what the person asked for. Half a minute is wide
// enough for that and nowhere near wide enough to swallow a mistake: the defect
// this bound exists for was a stamp TWO HOURS behind.
const standingPastGrace = 30 * time.Second
// standingWatchOffer is the marker file that remembers the one-time NOTICE:
// background checks are on, said once, ever. It lives under the store root
// beside the items rather than in the profile, because it is a fact about THIS
// store — a machine whose standing folder was thrown away has been told
// nothing.
//
// The name is the one the file has always had, and it is left alone: renaming
// it would say the sentence again to everybody who already heard the question
// it used to remember.
const standingWatchOffer = "watch-offer.json"
// standingWatchAnswer is that marker's whole content. It is journaled BEFORE
// [standing.Watch.Ensure] is called, so a person whose launchd would not take
// the file is somebody this build knows it has already spoken to — rather than
// somebody it tells again tomorrow.
//
// Told separates the two eras. Asked and Answer are what the one-time QUESTION
// wrote — keep checking when no window is open? — and a marker without Told is
// somebody who answered it; Told says this build put the timer on and said so,
// which is a different thing to have happened to a person and worth being able
// to tell apart in a folder somebody is reading a year from now.
type standingWatchAnswer struct {
Asked bool `json:"asked"`
Answer bool `json:"answer"`
Told bool `json:"told"`
At time.Time `json:"at"`
}
// The one dim line the conversation says the first time anything ever stands,
// and its honest other half.
//
// THEY ARE THE WHOLE OF WHAT IS SAID ABOUT THIS, EVER. The timer is installed
// without asking, so the person is owed the fact and the switch in one line
// they cannot miss and never have to read twice — and the cadence in it is
// [standing.IntervalWords] rather than a typed figure, because a sentence that
// spelled out five minutes would be the second place that number lived.
var (
standingBackgroundLine = "checks every " + standing.IntervalWords() +
", window or not · background checks under /settings"
standingBackgroundFailed = "could not install the background check · "
standingBackgroundWhere = " · background checks under /settings"
)
// standingBackgroundUpdate is the update word that line travels under. It is
// the ONE piece of news that is not about an item — the item is only what
// occasioned it — and internal/tui3 draws it as the bare sentence with no
// glyph and no name in front of it (standing.go's [standUpdateRow]).
const standingBackgroundUpdate = "background"
// standingStore is the slice of [standing.Store] this file uses, named as an
// interface so a test can watch what a ratified card writes. *standing.Store
// satisfies it; nothing else in this build does.
//
// It is DELIBERATELY NARROW. Every path, every lock and every file format is
// internal/standing's business and stays there; what a conversation needs is to
// make one, rewrite one, read one and list them.
type standingStore interface {
Create(standing.Item) (standing.Item, error)
Save(standing.Item) error
Get(id string) (standing.Item, error)
ForWorkspace(workspace string) ([]standing.Item, error)
Root() string
// ExchangeDir is where a home-made item's origin exchange ends up. It is
// asked for rather than assembled here for the reason the rest of this
// interface exists: every path under the store root is internal/standing's
// business, and a second answer to one of them would be a second truth.
ExchangeDir(id string) string
}
// standingItems answers which store this agent writes through: the test's fake
// when one was handed over, and the door's real one otherwise. A nil answer is
// a session with no ambient side at all, and the tool is not on its belt.
//
// IT IS THE CONFIG'S OWN ANSWER AND NOT A SECOND READING OF IT. The page's
// standing section is composed from the same availability
// ([Config.mayStand]) before there is an agent to ask, and two readings of two
// fields is exactly the drift beltfacts.go exists to prevent: a door that
// starts handing the store over some other way must not be able to put the
// tool on the belt and the "there is no scheduling here" sentence on the page.
func (a *Agent) standingItems() standingStore { return a.config.standingStore() }
// standingStore is that one reading. It is on the config because the prompt is
// rendered before the agent exists (agent.go's newAgent).
func (c Config) standingStore() standingStore {
if c.standingItems != nil {
return c.standingItems
}
if c.Standing == nil || c.Standing.Store == nil {
return nil
}
return c.Standing.Store
}
// standingWatch is this machine's timer, or nil where there is none. It is the
// same object the first ratified item installs and the same one the settings
// row turns, said once here so those two can never come to hold different
// timers.
func (a *Agent) standingWatch() standing.Watch {
if a.config.Standing == nil || a.config.Standing.Watch == nil {
return nil
}
return a.config.Standing.Watch
}
// standDescription is what the model reads before it calls, and most of it is
// RECOGNITION rather than mechanics: the tool is useless unless the model
// notices that an ordinary sentence was a standing one, and nothing else in
// this build watches for those words.
//
// AND RECOGNITION IS A TEST, NOT A WORD LIST. This description used to hand the
// model a bag of trigger words — "whenever", "every", "from now on", "make sure"
// — and a bag of words is a matcher a model runs instead of thinking: it caught
// "make sure this website you're building is 3 pages" and proposed a standing
// order for an acceptance criterion, which is the worst failure this tool has,
// because a card the person did not want teaches them to distrust every card
// after it. So what it carries now is the reasoning — the discharge test, what
// anchors a sentence to today's work, what separates a waking kind from a hold,
// and what to do when the answer is genuinely unclear — with a handful of
// canonical examples to calibrate it and nothing to pattern-match on.
//
// AND IT IS WRITTEN FOR DENSITY, BECAUSE THIS STRING IS BILLED ON EVERY REQUEST
// OF EVERY TURN. The belt's schemas ride in front of each request the model
// makes — dozens of them in one task — so a paragraph here is paid dozens of
// times while this comment is free. So the reasoning stays and the repetition
// went: the last paragraph used to walk every argument a second time, and every
// one of those sentences is now said ONCE, in the schema field it governs.
var standDescription = "Set up something that keeps working after this window is closed — a reminder, a watch on the world, a rule, or work that runs overnight — and manage the ones that already stand. THE PERSON NEVER NAMES THIS TOOL; you recognise it from what their sentence IS.\n\n" +
"THE DISCHARGE TEST decides it. Can this sentence be satisfied once and then forgotten? If it CAN, it is part of the work in front of you — an acceptance criterion, an instruction — and it does NOT stand, whatever words it is dressed in and even when it says \"make sure\": \"make sure this website you are building is 3 pages\" is discharged the moment the site has three pages. If it can NEVER be discharged — if work nobody has done yet could violate it tomorrow — it is standing: \"make sure the tests never break\".\n\n" +
"ANCHORING. A sentence about the artifact under construction RIGHT NOW binds the current work, whatever verbs it uses, and what anchors it is the GRAMMAR: a demonstrative pointing at the thing in front of you (\"this website you're building\"), or a present tense about work already under way (\"what you're doing\"). An \"always\", a \"never\" or an \"ensure\" inside such a sentence is EMPHASIS ON THIS WORK — a quality bar for the thing being built is acceptance, and acceptance is never a card.\n\n" +
"WAKING OR HOLDING. A standing sentence that names a moment, a rhythm or a condition gets the waking kind it names (\"remind me at 6\" is at, \"every Monday draft the update\" is every, \"tell me when CI goes red\" is probe, \"tonight run the suite\" is idle). One that names none of them — a rule, a convention, a preference — is when.kind hold.\n\n" +
"UNSURE MEANS INSTRUCTION PLUS AN OFFER. When the discharge test is genuinely unclear, bind the sentence to the work in front of you AND offer the standing version in one line of prose at the end of your reply. NEVER a card on a guess: a card they did not want costs their trust in every card after it.\n\n" +
"Doing a standing sentence once instead of proposing it answers a request they did not make: \"run the tests\" is work you do now, \"run the tests whenever I push\" is one of these. The `watch` tool is the near neighbour that is NOT this: a watch is a job inside this conversation and stops the moment the window closes, so anything that has to keep looking after they walk away belongs here and never there.\n\n" +
"Nothing stands until the person says yes: the card waits for them with no clock on it, and a session nobody is watching cannot set one up at all. Money is not yours to negotiate — omit rails and cost_words unless they named a limit. op=list shows what already stands here; op=pause, op=resume and op=stop take an id or the person's own words, and stop is permanent. op=change is not yours to call — it is what the card answers when they want it different."
var standSchemaJSON = `{"type":"object","properties":{` +
`"op":{"type":"string","enum":["propose","list","pause","resume","stop","change"],"description":"propose a new one, list what stands here, or pause, resume or stop one that does."},` +
`"words":{"type":"string","description":"THE PERSON'S OWN SENTENCE, verbatim, never a paraphrase: every card, row and note leads with it. On pause, resume and stop it names an item instead of its id."},` +
`"when":{"type":"object","description":"What wakes it. Only the fields this kind names are read.","properties":{` +
`"kind":{"type":"string","enum":["at","every","file","idle","probe","hold"],"description":"at: once at a moment, then it retires. every: a rhythm. file: a glob changing. idle: the machine quiet a while. probe: a look at the world judged against the person's words. hold: NEVER WAKES and so can never spend — the kind for a rule, a convention or a preference, a sentence with no moment, rhythm or condition in it; it rides automatically into the world of every conversation and task it reaches, which is how it is kept."},` +
`"at":{"type":"string","description":"The one moment of an at, a local RFC3339 stamp (\"2026-08-20T18:00:00+01:00\"). Work it out from the Now line in your instructions; NEVER shell out to read a clock. A moment ALREADY PASSED is refused, and the refusal says the time now — recompute from that, not from the Now line you already used. For a relative moment send in."},` +
`"in":{"type":"string","description":"An at's moment as a distance from RIGHT NOW: a Go duration (\"2m\", \"1h30m\"). codeaf resolves it at the instant you call and answers with the moment it landed on. Send at or in, never both."},` +
`"every":{"type":"string","description":"An every's rhythm: a five-field cron line (\"0 9 * * 1\") or a Go duration of at least a minute (\"20m\", \"2h\")."},` +
`"glob":{"type":"string","description":"A file watch's pattern, relative to the project."},` +
`"idle_for":{"type":"string","description":"How quiet the machine must have been for an idle item: a Go duration (\"45m\")."},` +
`"probe":{"type":"object","description":"One look at the world: EXACTLY ONE of a shell command or a belt tool with arguments.","properties":{` +
`"command":{"type":"string","description":"A shell command run in the project, whose output the judgment reads."},` +
`"tool":{"type":"string","description":"A tool on your belt to call instead, including one a connected account brought."},` +
`"args":{"type":"object","description":"That tool's arguments."}` +
`},"additionalProperties":false},` +
`"probe_every":{"type":"string","description":"How often to take that look, a Go duration. Defaults to how often anything is checked."},` +
`"hint":{"type":"string","description":"What a yes looks like, for the cheap judgment that reads the probe's output: \"yes when any run on main shows conclusion=failure\"."}` +
`},"additionalProperties":false},` +
`"does":{"type":"object","description":"What a firing does. Every waking kind needs one; a hold takes NONE, and sending one with a hold is refused.","properties":{` +
`"kind":{"type":"string","enum":["say","task"],"description":"say delivers one line to the person: into this conversation when it is open, else whichever conversation of this project they are in, else waiting on home and in the next one they open. task runs a brief in its own session, with a copy of its own and a cost row, the way propose_task's work runs."},` +
`"say":{"type":"string","description":"The line to deliver. {{evidence}} in it is replaced by what the probe found."},` +
`"brief":{"type":"string","description":"THE WORK, self-contained as propose_task's brief is: nobody will be there to ask. {{evidence}} is replaced by what the probe found."},` +
`"acceptance":{"type":"string","description":"How anybody checks the work is done."},` +
`"model":{"type":"string","description":"Model for the work, only when the person named one."},` +
`"isolate":{"type":"boolean","description":"Task only: keep a separate Git worktree for review. Set true for branch-only or PR-without-merge requests; shown on approval."},` +
`"max_steps":{"type":"integer","description":"Tool calls one firing's work may take (default ` + strconv.Itoa(standingRunSteps) + `)."}` +
`},"additionalProperties":false},` +
`"rails":{"type":"object","description":"Optional quiet backstops. Name money only when the person did; otherwise the card quotes the machine-wide daily allowance. A hold takes none — it never wakes, so it never spends. Only expires means anything on one.","properties":{` +
`"per_run_usd":{"type":"number","description":"The most one firing may spend, judgment included. Send only when they named a per-run limit; otherwise it quietly defaults to ` + strconv.FormatFloat(standDefaultPerRunUSD, 'f', 2, 64) + `."},` +
`"max_per_day":{"type":"integer","description":"Firings allowed in one local day. Send only when they named a count; otherwise it quietly defaults to ` + strconv.Itoa(standDefaultMaxPerDay) + `."},` +
`"expires":{"type":"string","description":"Local RFC3339 retirement time; omit for never. Must be future and at least one check (` + standing.Interval.String() + `) after its first firing, since expiry is checked before due work. One-offs retire on firing and need no end."}` +
`},"additionalProperties":false},` +
`"when_words":{"type":"string","description":"The cadence said back plainly — \"Mondays at 9am\". The card quotes this and never the spec, so never cron."},` +
`"cost_words":{"type":"string","description":"When the person named money, quote their limit in their words — \"at most a dollar a run\". Omit when they named none; codeaf quotes the shared allowance."},` +
`"guessed":{"type":"boolean","description":"True when YOU invented the cadence because they gave none. The card then asks rather than states."},` +
`"altitude":{"type":"string","enum":["conversation","project","machine"],"description":"HOW FAR IT REACHES, and the card always names it. conversation: this chat alone, dying with it. project: every conversation and task here. machine: everything they do on this computer. THEIR OWN SCOPE WORDS CHOOSE IT — \"just this chat\" is conversation, \"everywhere\" and \"all my projects\" are machine. Omit it when they said nothing about scope: widening it on your own judgment decides on their behalf."},` +
`"title":{"type":"string","description":"Three or four words for a row too narrow for their sentence — \"weekly update\". Their sentence still leads every screen."},` +
`"grant":{"type":"string","description":"One sentence, in their words, for what acting on this may do without asking — \"open a pull request but never merge it\". Send it only when they said something like it; with none, it may only tell them things."},` +
`"id":{"type":"string","description":"Which item pause, resume and stop are about. Their own words work too."}` +
`},"required":["op"],"additionalProperties":false}`
// standArguments is the wire form.
type standArguments struct {
Op string `json:"op"`
Words string `json:"words"`
WhenWords string `json:"when_words"`
CostWords string `json:"cost_words"`
Guessed bool `json:"guessed"`
ID string `json:"id"`
Altitude string `json:"altitude"`
Title string `json:"title"`
Grant string `json:"grant"`
When struct {
Kind string `json:"kind"`
At string `json:"at"`
In string `json:"in"`
Every string `json:"every"`
Glob string `json:"glob"`
IdleFor string `json:"idle_for"`
Probe struct {
Command string `json:"command"`
Tool string `json:"tool"`
Args json.RawMessage `json:"args"`
} `json:"probe"`
ProbeEvery string `json:"probe_every"`
Hint string `json:"hint"`
} `json:"when"`
Does struct {
Isolate bool `json:"isolate"`
Kind string `json:"kind"`
Say string `json:"say"`
Brief string `json:"brief"`
Acceptance string `json:"acceptance"`
Model string `json:"model"`
MaxSteps int `json:"max_steps"`
} `json:"does"`
Rails struct {
PerRunUSD *float64 `json:"per_run_usd"`
MaxPerDay *int `json:"max_per_day"`
Expires string `json:"expires"`
} `json:"rails"`
}
// standingTools is the belt's ambient family — one tool, present only where
// there is a store behind it (tools.go).
func (a *Agent) standingTools() []bare.Tool {
if a.standingItems() == nil {
return nil
}
return []bare.Tool{{
Name: "stand",
Description: standDescription,
Schema: json.RawMessage(standSchemaJSON),
Execute: a.standTool,
}}
}
// standTool is the belt's entry point, and it is [Agent.standDispatch] with the
// CLOCK STAMPED ON THE ANSWER.
//
// EVERY RESULT OF THIS TOOL ENDS WITH THE TIME, whichever op it was and whether
// it worked. This is the one tool whose whole subject is WHEN, and it is called
// at the exact moment the model most needs the real clock rather than the
// minute its instructions opened with — a `Now` line is at most [clockRefresh]
// old (prompt.go), and a model that has just been told a moment has already
// passed has to recompute from something. So it is told, here, in the shape the
// prompt uses, on a line of its own.
func (a *Agent) standTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
out, failed, err := a.standDispatch(ctx, args)
if err != nil {
return out, failed, err
}
return standingWithNow(out, time.Now()), failed, nil
}
// standingWithNow is that line, appended.
func standingWithNow(out string, now time.Time) string {
out = strings.TrimRight(out, "\n")
if out != "" {
out += "\n"
}
return out + "now: " + standingClock(now)
}
// standingClock is a moment as this tool and its refusals spell one: the local
// time to the minute and the numeric offset, which is exactly what a model
// needs to write an RFC3339 stamp back. It is the `Now` line's own shape minus
// the date (prompt.go's [nowLine]), because the date is carried in the one
// place a date matters — the refusal that says what day it now is.
func standingClock(moment time.Time) string { return moment.Format("15:04 -07:00") }
// standingPassed is the refusal a moment already gone earns, in the model's own
// grammar and carrying THE CURRENT TIME so the recomputation needs no second
// call. The tail is the field's own, because "send when.in instead" is advice
// about a reminder and not about an expiry.
func standingPassed(field string, moment, now time.Time, tail string) string {
return "Invalid arguments: " + field + " " + standingClock(moment) +
" has already passed — it is now " + standingClock(now) +
" (" + now.Format("Monday 2006-01-02") + "). " + tail
}
// standingClockExact is [standingClock] TO THE SECOND, and it exists for one
// refusal only.
//
// The minute is the right grain everywhere else, because a person names minutes
// and a model writes them back. It is the wrong grain for [standingRetires],
// where the whole mistake can live inside one minute: a model that wrote
// `in 1 minute · 23:11` for the words and `23:11` for the end, against a moment
// the engine resolved to 23:11:11, would otherwise be told that 23:11 is not
// after 23:11 and have nothing to work with.
func standingClockExact(moment time.Time) string { return moment.Format("15:04:05 -07:00") }
// standingRetires is the refusal an expiry earns for standing BEFORE the thing
// it is supposed to outlive — the same law [standingPassed] states, applied to
// the item's own moment rather than to the clock.
//
// It is one sentence in that refusal's grammar: the field, what is wrong with
// it, and the way out. `firing` is what the item's first waking would be and
// `named` is how to say that in the model's own vocabulary, since `when.at` is
// a field it can go and edit while a rhythm's first firing is only a
// consequence of one.
func standingRetires(moment, firing time.Time, named, tail string) string {
return "Invalid arguments: rails.expires " + standingClockExact(moment) +
" is not after " + named + " " + standingClockExact(firing) +
", so it would retire before it ever fired. " + tail
}
// standingOutlivedByACheck is the refusal an end earns for falling between the
// item's first firing and the next time anything looks at it.
//
// IT IS [standingRetires]' OTHER HALF AND IT NAMES THE CADENCE. A person reading
// "is not after" can see the mistake in the two stamps; a person reading "is less
// than one check after" cannot, unless the sentence says how far apart the checks
// are — so the figure is interpolated from [standing.Interval] rather than
// written out, because a cadence spelled twice is a cadence that will drift.
func standingOutlivedByACheck(moment, firing time.Time, named, tail string) string {
return "Invalid arguments: rails.expires " + standingClockExact(moment) +
" is less than one check after " + named + " " + standingClockExact(firing) +
", so a check can find it out of time at the same moment it would have found it due. " +
"Checks are " + standing.Interval.String() + " apart. " + tail
}
// standDispatch dispatches the six ops. Everything it can answer badly is an
// ordinary tool result rather than a Go error, the way every other tool on this
// belt answers: a card the model shaped wrongly is a card it can shape again.
func (a *Agent) standDispatch(ctx context.Context, args json.RawMessage) (string, bool, error) {
var parsed standArguments
if len(args) > 0 {
if err := decodeToolArguments(args, &parsed); err != nil {
return "Invalid arguments: " + err.Error(), true, nil
}
}
switch strings.ToLower(strings.TrimSpace(parsed.Op)) {
case "propose":
return a.standPropose(ctx, parsed)
case "list":
return a.standList()
case "pause":
return a.standSetStatus(parsed, standing.StatusPaused)
case "resume":
return a.standSetStatus(parsed, standing.StatusActive)
case "stop":
return a.standSetStatus(parsed, standing.StatusRetired)
case "change":
// The person's own word for "not like that". It is answered by the CARD
// and never by a call, so a model that reached for it here is being told
// where the door actually is rather than being failed.
return "change is what the person's card answers, not an op you call. Propose it again with what they corrected.", true, nil
case "":
return "Invalid arguments: op is required — propose, list, pause, resume or stop", true, nil
default:
return "Invalid arguments: no op called " + strconv.Quote(parsed.Op) + " — propose, list, pause, resume or stop", true, nil
}
}
// ── proposing ───────────────────────────────────────────────────────────────
// standPropose builds the item, puts the card in front of the person, and does
// exactly what they said.
func (a *Agent) standPropose(ctx context.Context, parsed standArguments) (string, bool, error) {
store := a.standingItems()
if store == nil {
return "there is nothing here to set one up with", true, nil
}
// ONE READING OF THE CLOCK FOR THE WHOLE CALL. `when.in`, the refusal of a
// moment that has passed and the refusal of an expiry that has passed all
// measure against the SAME instant; two readings a microsecond apart would
// be two answers to one question in a function whose whole subject is when.
item, problem := a.standingItem(parsed, time.Now())
if problem != "" {
return problem, true, nil
}
// VALIDATED BEFORE ANYBODY IS ASKED. The admission law is [standing.Item]'s
// own, and a card whose yes could only fail is worse than a refusal the
// model can act on this turn.
if err := item.Validate(); err != nil {
return "Invalid arguments: " + err.Error(), true, nil
}
notice := StandingNotice{
Item: item,
// ONE SOURCE OF TRUTH FOR THE CADENCE. The card, the item and the line
// this tool answers with all read [standing.When.Words], which is the
// model's own when_words or — when it sent none and the moment was
// worked out from a duration — the moment the engine landed on.
WhenWords: item.When.Words,
CostWords: a.standingCostWords(item, parsed),
Guessed: parsed.Guessed,
// AND THE ENGINE SAYS WHICH ANSWERS THIS CARD HAS. Both surfaces draw
// from this one list, so `once, not standing` is absent from a one-off
// reminder's card everywhere at once (answers.go's [StandingOptions]).
Options: StandingOptions(item),
}
answer, err := a.askStanding(ctx, ¬ice)
if err != nil {
switch {
case errors.Is(err, errStandingUnwatched):
return "nobody is here to say yes — this can only be set up in a conversation", true, nil
case errors.Is(err, errStandingUnanswered):
// THE TURN ENDED WITH THE CARD STILL UP, and it is said as exactly
// that. "They said no" would be this tool putting a sentence in
// somebody's mouth that they did not say, and "it declined on the
// clock" would describe a clock this build does not run.
return "the card was left unanswered — nothing was set up", false, nil
}
return "the card was never answered: the turn ended first", true, nil
}
switch {
case answer.Once:
if !StandingOnceIsAnAnswer(item) {
return "this card does not offer doing it once now; nothing was set up or run", true, nil
}
return standingOnceHandoff(item), false, nil
case !answer.Approved:
if correction := strings.TrimSpace(answer.Change); correction != "" {
// AND THE CORRECTION MAY BE ABOUT ANY OF IT. The card's one change
// door covers when it wakes and how far it reaches alike (tui3's
// [standChangeWord]), so a result that named only the cadence would
// have the model re-proposing the same reach it was just corrected
// on.
return "the person changed it: " + correction + "\nNothing stands yet. Propose it again with that — it may be about when it wakes, how far it reaches, or the words themselves.", false, nil
}
return "nothing was set up: the person said no.", false, nil
}
created, err := store.Create(item)
if err != nil {
// SAID PLAINLY AND NOT SWALLOWED. The person answered yes to a card, so
// the one thing that must never happen is the conversation carrying on
// as though something now stands.
return "nothing was set up: " + err.Error(), true, nil
}
created = a.standingFileTheExchange(store, created)
a.emitStandingUpdate("stood", created, "")
// Implicit setup reports to the surface once. The tool result also carries
// current availability: saving an item is not a promise that this home
// owns the shared timer, even after the first-setup notice was already sent.
a.standingBackgroundOn(store, created)
line := fmt.Sprintf("set up %s: %s", created.ID, created.Words)
if when := strings.TrimSpace(notice.WhenWords); when != "" {
line += "\nit wakes: " + when
}
line += "\n" + standingRatifiedLine
line += a.standingBackgroundLimitation(created)
return line, false, nil
}
// standingOnceHandoff records an approval, not execution. Keep the entire
// approved action on the tool boundary: the continuation must not reconstruct
// its brief, workspace, acceptance or watch probe from the scheduling request.
// Work stays in the ordinary turn under its existing tool permissions.
func standingOnceHandoff(item standing.Item) string {
payload := struct {
Decision string `json:"decision"`
Execution string `json:"execution"`
StandingSaved bool `json:"standing_saved"`
Instruction string `json:"next_step"`
Approved standing.Item `json:"approved_action"`
}{
Decision: "run_once_now",
Execution: "pending",
Instruction: "The person approved this action once now. This is not a decline. Execute approved_action in its workspace using the ordinary tools, respecting its grant, rails and the current permissions. For a watch, check its probe or condition once before the action. Ignore the future schedule: do not save or re-propose it. Report actual results or a concrete blocker; approval alone does not mean the work ran.",
Approved: item,
}
// standing.Item contains only validated JSON data from this tool's input.
raw, _ := json.Marshal(payload)
return string(raw)
}
// Waking items need a truthful timer status in every approval receipt, not just
// the first-setup UI notice. A saved permission rule never needs a timer.
func (a *Agent) standingBackgroundLimitation(item standing.Item) string {
if item.When.Kind == standing.WhenHold || a.config.Standing == nil || a.config.Standing.Watch == nil {
return ""
}
status, err := a.config.Standing.Watch.Status()
if err != nil {
return "\nBackground check status could not be confirmed. Say that the item was saved but do not promise it runs after the window closes."
}
if !status.Installed {
return "\nBackground checks are not installed for this home. Say that the item was saved, but scheduled work needs a codeaf window open for this home; do not promise it runs with the window closed. Do not take another profile's timer or suggest the item failed to save."
}
return ""
}
// standingRatifiedLine is what a model is told the instant something stands,
// and it is the WHOLE of what it may say next.
//
// "Say in ONE line what now stands and what it costs" is what it used to say,
// and a real model read that as a rule about the good part of the answer: it
// replied "The person wants a reminder in 1 minute. This is a `stand` with
// op=propose, when.kind=at…" and then the sentence. A person sitting in the
// conversation got the machinery vocabulary this codebase bans in anything
// anybody reads — said by the model, where no surface could scrub it. So the
// instruction now states what the whole reply is rather than how long its best
// part should be.
const standingRatifiedLine = "They answered the card. Your WHOLE reply is one short line saying what now stands and what it costs: no preamble, no working out, no naming this tool or its arguments, and do not ask again."
// standingItem turns one call into the object internal/standing keeps. Every
// refusal it can make is in the person's grammar rather than the schema's,
// because the model is the only reader and it has to fix the call.
func (a *Agent) standingItem(parsed standArguments, now time.Time) (standing.Item, string) {
words := strings.TrimSpace(parsed.Words)
if words == "" {
return standing.Item{}, "Invalid arguments: words is required — the person's own sentence, verbatim"
}
when, problem := standingWhen(parsed, now)
if problem != "" {
return standing.Item{}, problem
}
does, problem := standingDoes(parsed, when.Kind)
if problem != "" {
return standing.Item{}, problem
}
rails, problem := standingRails(parsed, when, now)
if problem != "" {
return standing.Item{}, problem
}
// THE PERSON'S CADENCE, SAID BACK, WINS OVER ANYTHING THE ENGINE WORKED
// OUT. when_words is the model's plain-words reading of what they asked
// for; the only time it is not the answer is when there is none, and then
// whatever [standingWhen] echoed stands (a resolved `in`, or nothing).
//
// A HOLD HAS NO CADENCE TO SAY BACK. A rule is not due at any time, so a
// `when ·` band under one would be the card reading a rhythm into the word
// "always" — and every surface afterwards would quote it as the moment this
// thing wakes up.
if words := strings.TrimSpace(parsed.WhenWords); words != "" && when.Kind != standing.WhenHold {
when.Words = words
}
item := standing.Item{
Words: words,
Workspace: a.standingWorkspace(),
Origin: a.standingOrigin(),
When: when,
Does: does,
Rails: rails,
Altitude: a.standingAltitude(parsed.Altitude),
// THE BRIEF IS HALF WRITTEN IN THIS WAVE, and honestly so: a title for a
// row too narrow for a sentence is something the model can write at
// proposal time, and the compiled prompt is not — nothing follows one
// yet, so nothing pretends to have one and [standing.Item.Prompt] reads
// as the person's own words.
Brief: standing.Brief{Title: strings.TrimSpace(parsed.Title)},
Grant: strings.TrimSpace(parsed.Grant),
}
item.Schema = standing.SchemaOf(item)
return item, ""
}
// standingAltitude is the reach the card will name.
//
// ALTITUDE IS DECIDED ON THE CARD AND NEVER GUESSED SILENTLY, so what the model
// sends is what stands, and a word that is not one of the three is refused by
// [standing.Item.Validate] before anybody is asked — there is no second list of
// them here.
//
// WHERE IT WAS SAID IS THE DEFAULT. A sentence said in a project is about that
// project, which is what every item made before altitudes were spelled already
// was; a sentence said in a conversation that belongs to no project at all is
// about the person, and this build already files those under their home
// directory ([Agent.standingWorkspace]) — so home IS the machine-wide
// convention, asked here rather than invented.
func (a *Agent) standingAltitude(raw string) standing.Altitude {
if named := standing.Altitude(strings.ToLower(strings.TrimSpace(raw))); named != "" {
return named
}
if house, err := os.UserHomeDir(); err == nil && strings.TrimSpace(house) != "" &&
filepath.Clean(house) == filepath.Clean(a.standingWorkspace()) {
return standing.AltitudeMachine
}
return standing.AltitudeProject
}
// standingKindWords is the closed list of shapes, said once, in the grammar the
// refusals use. It is ONE STRING because two spellings of a closed list is one
// of them forgetting the day a shape is added — which is exactly what happened
// when `hold` arrived and both refusals still offered five.
const standingKindWords = "at, every, file, idle, probe or hold"
func standingWhen(parsed standArguments, now time.Time) (standing.When, string) {
when := standing.When{
Kind: standing.WhenKind(strings.ToLower(strings.TrimSpace(parsed.When.Kind))),
Hint: strings.TrimSpace(parsed.When.Hint),
}
switch when.Kind {
case standing.WhenAt:
moment, echo, problem := standingAtMoment(parsed.When.At, parsed.When.In, now)
if problem != "" {
return when, problem
}
when.At = moment
// THE ECHO IS A FALLBACK AND NEVER AN OVERRIDE. [Agent.standingItem]
// puts the model's own when_words over the top of this when it sent
// any; what is left here is the case it sent none, where a card reading
// "in 2 minutes · 06:54" is the difference between a person checking a
// stamp and a person reading a sentence.
when.Words = echo
case standing.WhenEvery:
when.Every = strings.TrimSpace(parsed.When.Every)
if when.Every == "" {
return when, "Invalid arguments: when.every is required for a rhythm — a cron line or a duration"
}
case standing.WhenFile:
when.Glob = strings.TrimSpace(parsed.When.Glob)
case standing.WhenIdle:
idle, err := time.ParseDuration(strings.TrimSpace(parsed.When.IdleFor))
if err != nil {
return when, "Invalid arguments: when.idle_for is a duration like \"45m\""
}
when.IdleFor = idle
case standing.WhenHold:
// NOTHING WAKES IT, SO THERE IS NOTHING HERE TO GET WRONG. A hold's whole
// content is the person's sentence and how far it reaches; the moment, the
// rhythm, the glob and the probe are all fields about waking, and a rule
// has no waking to describe.
case standing.WhenProbe:
when.Probe = standing.Probe{
Command: strings.TrimSpace(parsed.When.Probe.Command),
Tool: strings.TrimSpace(parsed.When.Probe.Tool),
Args: parsed.When.Probe.Args,
}
// A LOOK WITH NO CADENCE IS TAKEN WHEN ANYTHING IS. The pass has one
// rhythm of its own; an item that named none simply rides it, which is
// the honest reading of "keep an eye on this" and not a guess.
when.ProbeEvery = standing.Interval
if every := strings.TrimSpace(parsed.When.ProbeEvery); every != "" {
parsedEvery, err := time.ParseDuration(every)
if err != nil {
return when, "Invalid arguments: when.probe_every is a duration like \"10m\""
}
when.ProbeEvery = parsedEvery
}
case "":
return when, "Invalid arguments: when.kind is required — " + standingKindWords
default:
return when, "Invalid arguments: no when called " + strconv.Quote(string(when.Kind)) + " — " + standingKindWords
}
return when, ""
}
// standingDoes is what a firing does, and it takes the kind that wakes it
// because ONE SHAPE HAS NO FIRING. A hold never wakes, so there is no moment for
// an action to be the content of; every other kind must say what it does.
func standingDoes(parsed standArguments, wakes standing.WhenKind) (standing.Action, string) {
does := standing.Action{
Isolate: parsed.Does.Isolate,
Kind: standing.ActionKind(strings.ToLower(strings.TrimSpace(parsed.Does.Kind))),
Say: strings.TrimSpace(parsed.Does.Say),
Brief: strings.TrimSpace(parsed.Does.Brief),
Acceptance: strings.TrimSpace(parsed.Does.Acceptance),
Model: strings.TrimSpace(parsed.Does.Model),
MaxSteps: parsed.Does.MaxSteps,
}
if wakes == standing.WhenHold {
// AND AN ACTION SENT WITH A HOLD IS REFUSED RATHER THAN DROPPED. A model
// that asked for a rule AND a line to say meant one of the two, and
// standing something up with an action nothing will ever run would leave
// the person holding a card whose promise cannot be kept.
if does.Kind != "" || does.Isolate {
return standing.Action{}, "Invalid arguments: a hold does nothing — it holds. Leave does out, or give it a when that wakes."
}
return standing.Action{}, ""
}
switch does.Kind {
case standing.ActionSay, standing.ActionTask:
case "":
return does, "Invalid arguments: does.kind is required — say or task"
default:
return does, "Invalid arguments: no action called " + strconv.Quote(string(does.Kind)) + " — say or task"
}
if does.MaxSteps < 0 {
return does, "Invalid arguments: does.max_steps cannot be negative"
}
return does, ""
}
// standingRails fills what the model left out. THE DEFAULTS ARE THIS FILE'S
// CONSTANTS and never a second set of numbers: the schema quotes them and the
// item is created with them.
//
// A HOLD IS THE ONE SHAPE THAT GETS NONE OF THEM. It cannot spend
// ([standing.Item.Spends]), so a budget written onto it would be a number
// nothing ever reads and every surface would still have to decide not to print.
// An END is different and is kept: "never touch the public API until the release
// lands" is a rule with a last day, and the pass retires it on that day.
//
// IT TAKES THE WHOLE `when` AND NOT ONLY ITS KIND, because an end is a claim
// about the item's own life and cannot be judged without the moment that life
// starts at — see the second refusal below.
func standingRails(parsed standArguments, when standing.When, now time.Time) (standing.Rails, string) {
rails := standing.Rails{}
if when.Kind != standing.WhenHold {
if parsed.Rails.PerRunUSD == nil {
rails.PerRunUSD = standDefaultPerRunUSD
} else {
rails.PerRunUSD = *parsed.Rails.PerRunUSD
}
if parsed.Rails.MaxPerDay == nil {
rails.MaxPerDay = standDefaultMaxPerDay
} else {
rails.MaxPerDay = *parsed.Rails.MaxPerDay
}
}
if expires := strings.TrimSpace(parsed.Rails.Expires); expires != "" {
moment, err := standingMoment(expires)
if err != nil {
return rails, "Invalid arguments: rails.expires " + err.Error()
}
// AND AN EXPIRY ALREADY GONE RETIRES THE ITEM BEFORE IT EVER FIRES, so
// it is refused for [standingAtMoment]'s reason and with its wording: a
// card answered yes that stood something up already dead is the worst
// of both endings.
if moment.Before(now.Add(-standingPastGrace)) {
return rails, standingPassed("rails.expires", moment, now,
"Work it out from that time, or leave it out for something that never expires.")
}
// AND AN EXPIRY STILL IN THE FUTURE DOES THE SAME DAMAGE WHEN IT STANDS
// BEFORE THE ITEM'S OWN FIRST FIRING, which is the half of this law that
// was missing (issue #188). The pass asks about the end FIRST, as rail
// one of internal/standing/tick.go's [Ticker.one], before anything can
// be due — so an end at or before the moment retires the item by every
// road there is and no clock could ever have delivered it. A person who
// answered yes to a card for a one-off reminder then waits for something
// that was already dead when they said so, and the only trace is a line
// in a log nobody reads: the worst of both endings again, and refused
// for the same reason.
//
// The defect this pins is exact arithmetic and not a slip: the model
// wrote `in 1 minute · 23:11` for the words and took `23:11` for the end
// from the same words, while the engine resolved the moment to
// 23:11:11 — eleven seconds later. So the refusal is spelled to the
// SECOND, or it would read as a moment that is not after itself.
if firing, named, has := standingFirstFiring(when, now); has {
switch {
case !moment.After(firing):
tail := "Put it after that moment, or leave it out for something that never expires."
if when.Kind == standing.WhenAt {
tail = "Put it after that moment, or leave it out — a one-off retires as it fires and needs no end at all."
}
return rails, standingRetires(moment, firing, named, tail)
// AND AN END INSIDE ONE CHECK IS THE SAME DEATH ARRIVING A MINUTE
// LATER. Nothing looks at an item continuously: a pass runs every
// [standing.Interval] and asks about the END FIRST, so an end that
// falls between the moment and the next pass is an item the pass
// finds expired at the same instant it would have found it due. It is
// arithmetic the refusal above cannot see — the end IS after the
// moment, by twenty-five seconds — and the outcome is identical: run
// 0 times, marked `expired`, one line in a log nobody reads.
case moment.Before(firing.Add(standing.Interval)):
tail := "Put the end at least that far after the moment, or leave it out for something that never expires."
if when.Kind == standing.WhenAt {
tail = "Put the end at least that far after the moment, or leave it out — a one-off retires as it fires and needs no end at all."
}
return rails, standingOutlivedByACheck(moment, firing, named, tail)
}
}
rails.Expires = moment
}
return rails, ""
}
// standingFirstFiring is the earliest moment an item could ever wake, for the
// two shapes whose first waking is arithmetic rather than a fact about the
// world, together with the name the refusal should call it by.
//
// THE OTHER SHAPES HAVE NO ANSWER HERE AND ARE NOT GUESSED AT. A file watch, an
// idle watch and a probe wake when the world does something, which may be in a
// second or never, so there is no moment an end could be measured against and
// the only honest check on those is the one against the clock. A hold never
// wakes at all, and an end on one is the whole point of keeping it: "never
// touch the public API until the release lands" retires on the release day.
//
// A rhythm whose spec this cannot read answers nothing rather than a refusal of
// its own, because [standing.Item.Validate] is what rejects an unreadable
// rhythm and two refusals for one mistake would send the model to fix the wrong
// field.
func standingFirstFiring(when standing.When, now time.Time) (moment time.Time, named string, has bool) {
switch when.Kind {
case standing.WhenAt:
if when.At.IsZero() {
return time.Time{}, "", false
}
return when.At, "when.at", true
case standing.WhenEvery:
next, err := standing.ParseEvery(when.Every)
if err != nil {
return time.Time{}, "", false
}
// The pass reads a rhythm's first due the same way, from the clock at
// the moment it first sees the item (tick.go's WhenEvery arm), so this
// is that item's own first firing and not a second opinion about it.
return next(now), "its first firing", true
}
return time.Time{}, "", false
}
// standingCostWords keeps person-named money word for word and otherwise says
// the one allowance that protects every standing item on this machine.
//
// A HOLD SAYS NOTHING ABOUT MONEY, which is the emptiness law reaching a whole
// band of the card: nothing wakes a rule, so nothing about it is ever bought,
// and quoting the day's allowance under one would be asking a person to weigh a
// figure that can never be drawn on ([standing.Item.Spends]).
func (a *Agent) standingCostWords(item standing.Item, parsed standArguments) string {
if !item.Spends() {
return ""
}
if parsed.Rails.PerRunUSD != nil || parsed.Rails.MaxPerDay != nil {
return strings.TrimSpace(parsed.CostWords)
}
if runtime := a.config.Standing; runtime != nil {
rail := runtime.DailyRailUSD
if runtime.DailyRail != nil {
rail = runtime.DailyRail()
}
if rail > 0 {
return "shares the day's $" + strconv.FormatFloat(rail, 'f', 2, 64) + " allowance"
}
}
return "shares the day's allowance"
}
// standingAtMoment answers the one moment of an `at`, from either of the two
// ways a model may say it.
//
// A STAMP IS ONE ANSWER AND A DURATION IS THE OTHER, and there is never a third
// road out of this function: two answers to one question are refused rather
// than reconciled, because picking one of a disagreeing pair silently is how a
// reminder lands at the wrong hour and nobody can see why.
//
// The duration is resolved against the clock HERE, at the instant of the call,
// and not against the Now line the model was given at the top of the session
// (prompt.go's nowLine says why): a conversation that has been open for an hour
// still means two minutes from now when the person says "in two minutes".
func standingAtMoment(rawAt, rawIn string, now time.Time) (moment time.Time, echo, problem string) {
rawAt, rawIn = strings.TrimSpace(rawAt), strings.TrimSpace(rawIn)
switch {
case rawAt != "" && rawIn != "":
return time.Time{}, "", "Invalid arguments: when.at and when.in are two answers to one question — send the stamp or the duration, not both"
case rawIn != "":
span, err := time.ParseDuration(rawIn)
if err != nil {
return time.Time{}, "", "Invalid arguments: when.in is a duration like \"2m\", \"90s\" or \"1h30m\""
}
if span <= 0 {
return time.Time{}, "", "Invalid arguments: when.in has to be a distance into the future"
}
landed := now.Add(span)
return landed, "in " + standingSpanWords(span) + " · " + landed.Format("15:04"), ""
}
parsed, err := standingMoment(rawAt)
if err != nil {
return time.Time{}, "", "Invalid arguments: when.at " + err.Error()
}
// A REMINDER CAN NEVER BE SET FOR A MOMENT THAT HAS PASSED. The engine used
// to take any stamp it could read, so a model whose `Now` line had gone
// stale proposed 05:42 at 07:34 and this accepted it — a card the person
// answered for a thing that could never fire. The refusal carries the
// CURRENT time in the shape the prompt uses, because the model has to
// recompute from something and a refusal that only says no costs another
// call to find out what now is.
if parsed.Before(now.Add(-standingPastGrace)) {
return time.Time{}, "", standingPassed("when.at", parsed, now,
`For a distance from now send when.in ("1m"); for a clock time compute it from now.`)
}
return parsed, "", ""
}
// standingSpanWords is a duration as somebody would say it out loud, which is
// what a card is read as. Go's own String() answers "1h30m0s", and a card that
// said that would be quoting a wire format at a person.
//
// It is deliberately coarse: whole hours and minutes down to a minute, seconds
// only under one minute, and no fractions anywhere. "in 1 hour 30 minutes" is
// the sentence; "in 1.5 hours" is arithmetic somebody has to check.
func standingSpanWords(span time.Duration) string {
span = span.Round(time.Second)
if span < time.Minute {
return standingCountWords(int(span/time.Second), "second")
}
span = span.Round(time.Minute)
hours, minutes := int(span/time.Hour), int(span%time.Hour/time.Minute)
switch {
case hours == 0:
return standingCountWords(minutes, "minute")
case minutes == 0:
return standingCountWords(hours, "hour")
}
return standingCountWords(hours, "hour") + " " + standingCountWords(minutes, "minute")
}
// standingCountWords is "1 minute" and "2 minutes" — the plural nobody notices
// until it is wrong.
func standingCountWords(count int, unit string) string {
if count == 1 {
return "1 " + unit
}
return strconv.Itoa(count) + " " + unit + "s"
}
// standingMoment reads a stamp the way a model actually writes one: RFC3339
// first, then the two forms it reaches for when it forgets the offset. Both of
// those are read in the machine's own zone, which is the only zone a person
// saying "at six" could have meant.
func standingMoment(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, errors.New("is required — a local RFC3339 stamp")
}
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04"} {