Repository navigation
Expand file tree
/
Copy pathmain.zig
More file actions
1749 lines (1645 loc) · 78.6 KB
/
Copy pathmain.zig
File metadata and controls
1749 lines (1645 loc) · 78.6 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
// jevx - ask TypeSafe's Jev decision model typed questions, in one line each.
// The user guide is README.md beside this file; this header is the contract it describes.
// jevx 'urgent? Does this convey urgency?' <<<"$msg" 0.95
// jevx 'team/ Which team? \| billing: payments \| technical: bugs \| sales'
// billing 0.96
// jevx 'mood# How frustrated? \| calm \| frustrated \| furious'
// 1.04 0.94
// jevx -q 'spam?>.8 Is this unsolicited bulk mail?' <mail && mv mail spam/ (see Trust)
// jevx -l 'fruit?>.5 Is \0 a fruit?' <words (prints the fruits)
//
// Jev is a decision model: it does not write text, it takes a state and a
// map of typed questions and returns a typed answer with a probability for
// each. The wire format is JSON (docs.typesafe.ai/api); this feat is a compiler
// from a terse, vim-regex-flavoured line to that JSON, so a shell script never
// hand-writes nested quoting to ask "is this urgent?".
//
// ## Question syntax — one argv word (or one -f line) per question
//
// [KEY] SIGIL [GATE] SP INSTRUCTIONS { \| ALT }
//
// SIGIL ? noul yes/no; answer is P(yes) in 0..1
// / choice one of the ALTs (2..255); answer is the option + confidence
// # score ordered ALTs, low → high (2..10); answer is the
// probability-weighted level index + confidence
// KEY [A-Za-z0-9_.-]+, names the answer. Omitted → q1, q2, ... by position.
// ALT choice `opt` or `opt: description` (the opt is what comes back)
// noul `y: what yes means` / `n: what no means` (both optional)
// score `description` of that level
//
// Escapes, as in a vim pattern: `\|` separates alternatives, `\\` is a
// backslash, `\n` a newline, and `\0` is "the current item" in -l mode (the
// whole match, like `\0` in :s). Any other `\x` is passed through as written,
// so backticked state paths — Is `user.plan` "pro"? — need no escaping.
//
// GATE turns the answer into an exit status, the way `grep -q` does:
// noul >N >=N <N <=N urgent?>.7
// choice =OPT !=OPT then optional ~N team/=billing~.8
// score >N >=N <N <=N, optional ~N mood#>=1.5
// `~N` requires confidence >= N; it may stand alone (`team/~.8`). A gate on
// an option the question does not offer is a usage error, not a silent fail.
//
// ## State — what the questions are about
//
// stdin (default), `-s TEXT`, or `-S FILE`. State that parses as a JSON object
// or array is sent as structure (so backtick paths like `user.plan` resolve);
// anything else is sent as text. `-t` forces text.
//
// -l lines mode, the grep shape: each non-empty stdin line is an item, and
// every question is asked once per item in one request (Jev evaluates
// them in parallel). The line rides inside its question as
// {"item": LINE, "question": ...}, `\0` naming it; -s/-S is shared state.
// With a gate, the lines that pass are printed (-v: the ones that fail);
// without one, each line is printed after its answers. -b N items per
// request (default 100, at most 1000). -0 (`set nul`) is -l with
// NUL-separated items in and out, so an item may span lines: a function,
// a hunk. Jev judges small pieces far better than one big state, so
// split large input this way. Requests are packed by compiled size, not
// just by -b: as many items as fit Jev's context go in each, and an item
// too big to fit alone is an error naming it. stdin is read once and is capped
// at 16 MiB, so `-S -` and `-l` cannot share it.
//
// ## Scripts — .jevx
//
// A .jevx file is a -f question file you can execute:
//
// #!/usr/bin/env jevx
// " triage.jevx — ./triage.jevx < ticket, or ./triage.jevx "ticket text"
// set probs export
// team/ Which team? \| billing: payments \| technical: bugs \| sales
// urgent?>.6 Is the customer blocked right now?
//
// The kernel runs it as `jevx ./triage.jevx ARGS`; a first argument ending in
// .jevx (or `-x FILE` first) is script mode. Without jevx on PATH, zish can
// run it: `#!/usr/bin/env -S zish -c 'feat run jevx -x "$0" "$@"'`.
//
// `set` lines are options, vim-style, several per line: lines probs quiet
// text invert json export[=PFX] model=M batch=N — an unknown name is an
// error. They apply first; the command line adds to them, and its -m/-b win.
// Words left on the command line become the state, joined by spaces ("$*"),
// so a script reads stdin or its arguments, never both.
//
// -e (`set export`) prints shell assignments instead of the table, for
// `eval "$(./triage.jevx < t)"`: jev_team='billing' jev_team_conf='0.99'
// jev_urgent='0.78'. Names are PREFIX+KEY with non-identifier bytes as `_`
// (-E PFX / export=PFX to change jev_), values are single-quoted, and two
// keys that would land on one variable are a usage error.
//
// ## Output — stdout is data
//
// One line per question: `KEY VALUE...`, KEY dropped when there is only one
// question so `x=$(jevx ...)` is the bare value. noul → `P`, choice → `OPT
// CONF`, score → `SCORE CONF`. -p appends the distribution in your order
// (`opt=P`, score levels by index). -q prints nothing. -j prints the raw
// response; -n prints the compiled request and sends nothing.
//
// exit 0 answered (and every gate passed / -l printed a line)
// 1 a gate failed / -l printed nothing
// 2 could not decide: usage error, no key, HTTP or parse failure
// 1 vs 2 is grep's split on purpose: `jevx -q ... && act` must never act on an
// outage, and `|| fallback` must be able to tell "no" from "unknown".
//
// ## Backend
//
// OpenRouter's Decisions API (POST /api/alpha/decisions), model
// typesafe/jev-1.13. OpenRouter does not accept TypeSafe's `jev-latest`
// alias, so the default is pinned; the response's versioned id is in -j.
// JEVX_MODEL / -m override the model; JEVX_BACKEND=typesafe talks to
// api.typesafe.ai directly (model jev-latest). JEVX_ENDPOINT overrides the URL
// (https only) and then takes its key from JEVX_API_KEY alone — the
// OpenRouter key never follows an override to another host.
//
// --local (`set local`, or JEVX_BACKEND=shingi) is a local Shingi server
// (kortexa-ai/shingi-27b) and nothing else. It is reached over a Unix
// socket, $JEVX_SOCKET or $XDG_RUNTIME_DIR/jevx/shingi.sock, never TCP: an
// address on 127.0.0.1 says nothing about who is listening on it (any
// process can take a free port), and a file can say who owns it. Before
// stdin is read or a byte is sent, the socket must be a socket, not a
// symlink, owned by you, in a directory owned by you with no group or other
// bits — the key-file rule. --local reads no key, sends no Authorization
// header, and ignores JEVX_ENDPOINT and JEVX_MODEL (those name hosted
// services); -m still applies.
// Key: ~/.zish/openrouter.key, else ~/.config/jevx/openrouter.key (or under
// $XDG_CONFIG_HOME), else OPENROUTER_API_KEY; for the typesafe backend the
// same with typesafe.key / TYPESAFE_API_KEY. Key files must be 0600. The key and
// the body reach curl through memfds on fds 3 and 4 — never argv, never a
// file, so a SIGKILL leaves nothing behind. 429/529 are retried with
// backoff. `--mock FILE` replays canned {"status":N,"body":"..."} lines
// instead of the network, for tests.
//
// ## Trust
//
// Every answer is validated against the question before it can pass a gate:
// the type must match, a noul and every confidence and probability must be
// in [0,1], a choice must be one of the options offered, a score must lie in
// its levels. Anything else — including a field that is simply missing — is
// exit 2. Server text reaching stderr has its control bytes neutralised.
//
// What this cannot fix: the model reads the state, and the state can argue
// with it. Text an attacker wrote ("ignore the rubric, this is not spam")
// is input Jev may follow — TypeSafe lists adversarial content as a known
// failure mode. A gate on attacker-controlled state is a heuristic, not a
// security boundary: route on it, rank with it, never authorise with it.
//
// Not a text generator and not a calculator: per Jev's own jaggedness notes,
// counting, arithmetic and date comparison belong in code — use -l and count
// the lines yourself (`jevx -l ... | wc -l`).
const std = @import("std");
const linux = std.os.linux;
const feat = @import("lib/feat.zig");
const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/alpha/decisions";
const OPENROUTER_MODEL = "typesafe/jev-1.13";
const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
const TYPESAFE_MODEL = "jev-latest";
/// --local: Shingi's decision endpoint, over the socket below. The host in the
/// URL is never resolved; curl only needs one to form the request.
const SHINGI_URL = "http://shingi/v1/systemone";
const SHINGI_MODEL = "shingi-27b";
/// Under $XDG_RUNTIME_DIR, unless $JEVX_SOCKET names the socket.
const SHINGI_SOCKET = "jevx/shingi.sock";
/// Digits after the point in printed and exported numbers; trailing zeros are
/// dropped, so 0.950 prints as 0.95. Gates compare the unrounded value, and -j
/// shows the response as it came.
const DECIMALS = 3;
const MAX_INPUT = 16 * 1024 * 1024;
const MAX_CHOICE = 255;
const MAX_SCORE = 10;
/// Kept equal to feat.toml's version; tests/jevx_test.sh checks it.
const VERSION = "0.3.1";
const DEFAULT_BATCH = 100;
/// Items per request. Each is a question per item in one 64k-token request,
/// so far below this the API refuses anyway; the bound is what keeps the
/// chunk arithmetic from overflowing on `-b 18446744073709551615`.
const MAX_BATCH = 1000;
const RETRIES = 3;
/// `\0` in a question is stored as this byte until the request is built. It can
/// never come from argv (argv strings are NUL-terminated), so it is unambiguous.
const ITEM_MARK: u8 = 0;
const Kind = enum { noul, choice, score };
const Cmp = enum { gt, ge, lt, le, eq, ne };
const Gate = struct {
cmp: ?Cmp = null,
num: f64 = 0,
opt: []const u8 = "",
conf: ?f64 = null,
};
const Alt = struct { key: []const u8, desc: ?[]const u8 };
const Question = struct {
key: []const u8,
kind: Kind,
instr: []const u8,
alts: []const Alt,
gate: ?Gate = null,
/// `-J key=JSON`: the question object verbatim; kind/alts are unused.
raw: ?[]const u8 = null,
};
const Answer = struct {
kind: Kind,
value: f64 = 0, // noul P(yes), or score
choice: []const u8 = "",
conf: ?f64 = null,
probs: ?std.json.ObjectMap = null,
};
var usage_msg: []const u8 = "";
// ===========================================================================
// the question compiler (pure, unit-tested below)
// ===========================================================================
fn isKeyChar(c: u8) bool {
return std.ascii.isAlphanumeric(c) or c == '_' or c == '-' or c == '.';
}
fn fail(msg: []const u8) error{Usage} {
usage_msg = msg;
return error.Usage;
}
/// Resolve escapes and split on `\|`. Returns the pieces, unescaped.
fn splitAlts(a: std.mem.Allocator, text: []const u8) ![]const []const u8 {
var parts: std.ArrayListUnmanaged([]const u8) = .empty;
var cur: std.ArrayListUnmanaged(u8) = .empty;
var i: usize = 0;
while (i < text.len) : (i += 1) {
const c = text[i];
if (c == '\\' and i + 1 < text.len) {
const n = text[i + 1];
switch (n) {
'|' => {
try parts.append(a, try cur.toOwnedSlice(a));
i += 1;
continue;
},
'\\' => try cur.append(a, '\\'),
'n' => try cur.append(a, '\n'),
'0' => try cur.append(a, ITEM_MARK),
else => {
try cur.append(a, '\\');
try cur.append(a, n);
},
}
i += 1;
continue;
}
try cur.append(a, c);
}
try parts.append(a, try cur.toOwnedSlice(a));
return parts.toOwnedSlice(a);
}
fn parseNum(s: []const u8) !f64 {
if (s.len == 0) return fail("gate needs a number");
const f = std.fmt.parseFloat(f64, s) catch return fail("gate: not a number");
// parseFloat takes "nan" and "inf"; every comparison with NaN is false, so
// `?>nan` was a gate that failed forever without saying why.
if (!std.math.isFinite(f)) return fail("gate: number must be finite");
return f;
}
/// GATE grammar, by kind. `s` is everything between the sigil and the space.
fn parseGate(kind: Kind, s: []const u8) !?Gate {
if (s.len == 0) return null;
var g = Gate{};
var body = s;
if (kind != .noul) {
if (std.mem.indexOfScalar(u8, s, '~')) |t| {
g.conf = try parseNum(s[t + 1 ..]);
body = s[0..t];
}
}
if (body.len == 0) {
if (g.conf == null) return fail("empty gate");
return g;
}
if (kind == .choice) {
if (std.mem.startsWith(u8, body, "!=")) {
g.cmp = .ne;
g.opt = body[2..];
} else if (body[0] == '=') {
g.cmp = .eq;
g.opt = body[1..];
} else return fail("choice gate is =OPT or !=OPT");
if (g.opt.len == 0) return fail("choice gate names no option");
return g;
}
var rest: []const u8 = undefined;
if (std.mem.startsWith(u8, body, ">=")) {
g.cmp = .ge;
rest = body[2..];
} else if (std.mem.startsWith(u8, body, "<=")) {
g.cmp = .le;
rest = body[2..];
} else if (body[0] == '>') {
g.cmp = .gt;
rest = body[1..];
} else if (body[0] == '<') {
g.cmp = .lt;
rest = body[1..];
} else return fail("gate is >N, >=N, <N or <=N");
g.num = try parseNum(rest);
return g;
}
/// Blanks around a question's parts. Newlines count: a question spread over
/// lines inside one shell quote ('team/ Which?\n \\| a \\| b') must not carry
/// "\n" into its instructions or options.
const WS = " \t\r\n";
fn parseQuestion(a: std.mem.Allocator, src: []const u8, pos: usize) !Question {
const s = std.mem.trimStart(u8, src, WS);
var i: usize = 0;
while (i < s.len and isKeyChar(s[i])) i += 1;
if (i == s.len) return fail("question needs a sigil: ? (noul), / (choice) or # (score)");
const kind: Kind = switch (s[i]) {
'?' => .noul,
'/' => .choice,
'#' => .score,
else => return fail("question needs a sigil: ? (noul), / (choice) or # (score)"),
};
const key = if (i == 0) try std.fmt.allocPrint(a, "q{d}", .{pos + 1}) else s[0..i];
const gate_end = std.mem.indexOfAnyPos(u8, s, i + 1, WS) orelse
return fail("question has no instructions after its sigil");
const gate = try parseGate(kind, s[i + 1 .. gate_end]);
const parts = try splitAlts(a, s[gate_end + 1 ..]);
const instr = std.mem.trim(u8, parts[0], WS);
if (instr.len == 0) return fail("question has empty instructions");
var alts: std.ArrayListUnmanaged(Alt) = .empty;
for (parts[1..]) |raw| {
const p = std.mem.trim(u8, raw, WS);
if (p.len == 0) return fail("empty alternative between \\|");
switch (kind) {
.score => try alts.append(a, .{ .key = "", .desc = p }),
.choice, .noul => {
var k = p;
var d: ?[]const u8 = null;
if (std.mem.indexOfScalar(u8, p, ':')) |c| {
k = std.mem.trim(u8, p[0..c], WS);
const dd = std.mem.trim(u8, p[c + 1 ..], WS);
if (dd.len > 0) d = dd;
}
if (k.len == 0) return fail("alternative has an empty name before ':'");
if (kind == .noul) {
const yes = eqlAny(k, &.{ "y", "yes", "true" });
const no = eqlAny(k, &.{ "n", "no", "false" });
if (!yes and !no) return fail("noul alternatives are y: ... and n: ...");
if (d == null) return fail("noul alternative needs a description after ':'");
k = if (yes) "true" else "false";
}
for (alts.items) |prev| if (std.mem.eql(u8, prev.key, k))
return fail("duplicate alternative");
try alts.append(a, .{ .key = k, .desc = d });
},
}
}
switch (kind) {
.noul => {},
.choice => {
if (alts.items.len < 2) return fail("choice needs at least 2 options (\\| opt \\| opt)");
if (alts.items.len > MAX_CHOICE) return fail("choice takes at most 255 options");
},
.score => {
if (alts.items.len < 2) return fail("score needs at least 2 levels (\\| low \\| high)");
if (alts.items.len > MAX_SCORE) return fail("score takes at most 10 levels");
},
}
if (gate) |g| {
if (g.conf) |cf| if (cf < 0 or cf > 1) return fail("~confidence is in [0,1]");
if (g.cmp != null and kind == .noul and (g.num < 0 or g.num > 1))
return fail("a noul gate compares a probability: N in [0,1]");
if (g.cmp != null and kind == .score and (g.num < 0 or g.num > @as(f64, @floatFromInt(alts.items.len - 1))))
return fail("a score gate is a level index: N in [0, levels-1]");
}
if (gate) |g| if (g.cmp != null and kind == .choice) {
var known = false;
for (alts.items) |al| {
if (std.mem.eql(u8, al.key, g.opt)) known = true;
}
if (!known) return fail("gate names an option the choice does not offer");
};
return .{ .key = key, .kind = kind, .instr = instr, .alts = try alts.toOwnedSlice(a), .gate = gate };
}
fn eqlAny(s: []const u8, opts: []const []const u8) bool {
for (opts) |o| if (std.ascii.eqlIgnoreCase(s, o)) return true;
return false;
}
/// A -f file: one question per line. Vimscript conventions: a line whose first
/// non-blank is `"` is a comment, and a line whose first non-blank is `\`
/// continues the previous one: the blanks before the `\` and the `\` itself are
/// dropped and the rest is joined on with no space added, as in Vim, so
/// `\ word` supplies its own. A `\|` line keeps its `\|` — it starts an ALT.
fn parseQuestionFile(a: std.mem.Allocator, text: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void {
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, " \t\r");
const t = std.mem.trimStart(u8, line, " \t");
if (t.len == 0 or t[0] == '"') continue;
// `\|` at line start is an alternative separator, which is a
// continuation anyway; keep it, drop only a lone continuation `\`.
if (t[0] == '\\' and out.items.len > 0) {
const rest = if (t.len > 1 and t[1] == '|') t else t[1..];
const prev = out.items[out.items.len - 1];
out.items[out.items.len - 1] = try std.mem.concat(a, u8, &.{ prev, rest });
continue;
}
try out.append(a, t);
}
}
/// A .jevx script: a question file that can be executed. The first line may
/// be a `#!` line; `set` lines are options, vim-style (`set lines probs`,
/// `set model=typesafe/jev-1.13`); everything else is a question, with the -f
/// conventions (`"` comments, `\` continuation). Options become argv words
/// placed *before* the command line's, so `./x.jevx -p` adds to what the file
/// set and a repeated value option (-m, -b) on the command line wins.
fn parseScript(a: std.mem.Allocator, text: []const u8, srcs: *std.ArrayListUnmanaged([]const u8), flags: *std.ArrayListUnmanaged([:0]const u8)) !void {
var body = text;
if (std.mem.startsWith(u8, body, "#!")) {
body = if (std.mem.indexOfScalar(u8, body, '\n')) |nl| body[nl + 1 ..] else "";
}
var logical: std.ArrayListUnmanaged([]const u8) = .empty;
try parseQuestionFile(a, body, &logical);
for (logical.items) |l| {
if (std.mem.eql(u8, l, "set") or std.mem.startsWith(u8, l, "set ") or std.mem.startsWith(u8, l, "set\t")) {
var it = std.mem.tokenizeAny(u8, l[3..], " \t");
var any = false;
while (it.next()) |tok| {
any = true;
try setOption(a, tok, flags);
}
if (!any) return fail("`set` names no option");
} else try srcs.append(a, l);
}
}
/// One `set` word → the argv words it means. Unknown names are an error: a
/// typo (`set line`) must not silently run in the wrong mode.
fn setOption(a: std.mem.Allocator, tok: []const u8, flags: *std.ArrayListUnmanaged([:0]const u8)) !void {
const eq = std.mem.indexOfScalar(u8, tok, '=');
const name = if (eq) |e| tok[0..e] else tok;
const val: ?[]const u8 = if (eq) |e| tok[e + 1 ..] else null;
const Bare = struct { n: []const u8, f: [:0]const u8 };
const bare = [_]Bare{
.{ .n = "lines", .f = "-l" }, .{ .n = "probs", .f = "-p" }, .{ .n = "quiet", .f = "-q" },
.{ .n = "text", .f = "-t" }, .{ .n = "invert", .f = "-v" }, .{ .n = "json", .f = "-j" },
.{ .n = "nul", .f = "-0" }, .{ .n = "local", .f = "--local" },
};
for (bare) |b| if (std.mem.eql(u8, name, b.n)) {
if (val != null) return fail("this `set` option takes no value");
return flags.append(a, b.f);
};
const flag: [:0]const u8 = if (std.mem.eql(u8, name, "model"))
"-m"
else if (std.mem.eql(u8, name, "batch"))
"-b"
else if (std.mem.eql(u8, name, "export")) {
if (val) |v| {
try flags.append(a, "-E");
return flags.append(a, try a.dupeZ(u8, v));
}
return flags.append(a, "-e");
} else return fail("unknown `set` option (lines nul probs quiet text invert json local export[=PFX] model=M batch=N)");
try flags.append(a, flag);
try flags.append(a, try a.dupeZ(u8, val orelse return fail("this `set` option needs =VALUE")));
}
fn isIdent(s: []const u8) bool {
if (s.len == 0 or std.ascii.isDigit(s[0])) return false;
for (s) |c| if (!(std.ascii.isAlphanumeric(c) or c == '_')) return false;
return true;
}
/// PREFIX + KEY as a shell identifier: every byte outside [A-Za-z0-9_] → `_`.
/// The prefix (default `jev_`) is what keeps a question named `PATH` or `IFS`
/// from assigning the variable of that name when the output is eval'd.
fn shellName(a: std.mem.Allocator, prefix: []const u8, key: []const u8) ![]u8 {
const n = try std.mem.concat(a, u8, &.{ prefix, key });
for (n[prefix.len..]) |*c| {
if (!(std.ascii.isAlphanumeric(c.*) or c.* == '_')) c.* = '_';
}
return n;
}
/// `NAME='value'`, single-quoted so eval never expands it; a `'` is closed,
/// escaped and reopened. Values are a validated option name or a number, but
/// the quoting does not rely on that.
fn exportLine(b: *std.ArrayListUnmanaged(u8), a: std.mem.Allocator, name: []const u8, suffix: []const u8, value: []const u8) !void {
try b.appendSlice(a, name);
try b.appendSlice(a, suffix);
try b.appendSlice(a, "='");
for (value) |c| {
if (c == '\'') try b.appendSlice(a, "'\\''") else try b.append(a, c);
}
try b.appendSlice(a, "'\n");
}
fn exportAnswer(b: *std.ArrayListUnmanaged(u8), a: std.mem.Allocator, name: []const u8, ans: Answer) !void {
var nb: [64]u8 = undefined;
switch (ans.kind) {
.noul => try exportLine(b, a, name, "", numText(&nb, ans.value)),
.choice => {
try exportLine(b, a, name, "", ans.choice);
try exportLine(b, a, name, "_conf", numText(&nb, ans.conf.?));
},
.score => {
try exportLine(b, a, name, "", numText(&nb, ans.value));
try exportLine(b, a, name, "_conf", numText(&nb, ans.conf.?));
},
}
}
// ===========================================================================
// request building
// ===========================================================================
const Build = struct {
a: std.mem.Allocator,
b: std.ArrayListUnmanaged(u8) = .empty,
fn raw(self: *Build, s: []const u8) !void {
try self.b.appendSlice(self.a, s);
}
/// A JSON string, verbatim: data (an item, the model id, an option key)
/// never has item references expanded, so a NUL in it is just \u0000.
fn plain(self: *Build, s: []const u8) !void {
try self.raw("\"");
try feat.jsonEscape(&self.b, self.a, s);
try self.raw("\"");
}
/// A JSON string, with ITEM_MARK expanded to `item` — backticked, the
/// form Jev resolves against the field of that name in the question's own
/// instructions object (see appendQuestion).
fn str(self: *Build, s: []const u8, in_item: bool) !void {
try self.raw("\"");
var rest = s;
while (std.mem.indexOfScalar(u8, rest, ITEM_MARK)) |m| {
try feat.jsonEscape(&self.b, self.a, rest[0..m]);
if (!in_item) return fail("\\0 (the current item) needs -l");
try self.raw("`item`");
rest = rest[m + 1 ..];
}
try feat.jsonEscape(&self.b, self.a, rest);
try self.raw("\"");
}
};
/// One question. In -l mode `item` is that line, and it rides *inside* the
/// question: instructions become {"item": LINE, "question": TEXT}, the
/// structured form the API documents for "data this question refers to".
///
/// It used to be a reference into one shared state, {"items":[...]} with
/// `items[i]` in each question. Measured on 21 feat descriptions asked "does
/// this make network requests?", that ranked `snf` (0.82) and `verify` (0.86)
/// above `gf` (0.51) and drifted upward with list position — Jev's documented
/// weakness with indirection over a large state. Inline, the same single
/// request ranked web/aur/gf on top and cnt/rand/pk at the bottom, matching
/// one request per line.
fn appendQuestion(bld: *Build, q: Question, key_suffix: ?usize, item: ?[]const u8) !void {
const in_item = item != null;
try bld.raw("\"");
try feat.jsonEscape(&bld.b, bld.a, q.key);
if (key_suffix) |n| try bld.b.print(bld.a, ".{d}", .{n});
try bld.raw("\":");
if (q.raw) |r| return bld.raw(r);
try bld.raw("{\"type\":\"");
try bld.raw(@tagName(q.kind));
try bld.raw("\",\"instructions\":");
if (item) |line| {
try bld.raw("{\"item\":");
try bld.plain(line);
try bld.raw(",\"question\":");
if (std.mem.indexOfScalar(u8, q.instr, ITEM_MARK) == null) {
const pre = try std.fmt.allocPrint(bld.a, "Regarding `item`: {s}", .{q.instr});
try bld.str(pre, true);
} else try bld.str(q.instr, true);
try bld.raw("}");
} else try bld.str(q.instr, false);
switch (q.kind) {
.noul => if (q.alts.len > 0) {
try bld.raw(",\"criteria\":{");
for (q.alts, 0..) |al, j| {
if (j > 0) try bld.raw(",");
try bld.raw("\"");
try bld.raw(al.key);
try bld.raw("\":");
try bld.str(al.desc.?, in_item);
}
try bld.raw("}");
},
.choice => {
try bld.raw(",\"criteria\":{");
for (q.alts, 0..) |al, j| {
if (j > 0) try bld.raw(",");
try bld.plain(al.key);
try bld.raw(":");
if (al.desc) |d| try bld.str(d, in_item) else try bld.raw("null");
}
try bld.raw("}");
},
.score => {
try bld.raw(",\"criteria\":[");
for (q.alts, 0..) |al, j| {
if (j > 0) try bld.raw(",");
try bld.str(al.desc.?, in_item);
}
try bld.raw("]");
},
}
try bld.raw("}");
}
/// Request-size budget, in bytes of compiled JSON. Jev's context is 64k tokens
/// for the state plus every question, and 32k for the state plus the longest
/// one. Code tokenizes at roughly 3 bytes a token and dense text near 2, so
/// these keep a request inside both limits with margin. Measured: 35 chunks
/// of `agent` source × 3 questions in one request was `max_tokens_exceeded`.
const MAX_REQUEST_BYTES = 100_000;
const MAX_ONE_QUESTION_BYTES = 56_000;
var plan_bad_item: usize = 0;
var plan_bad_bytes: usize = 0;
/// Split `items` into requests by what they compile to, not by a guessed
/// count: consecutive items join a request while its compiled size stays
/// under MAX_REQUEST_BYTES and its item count under `max_items` (-b). Each
/// item's questions are compiled once here to measure them. An item that
/// cannot fit even alone is error.ItemTooLarge — splitting it is the
/// caller's job, since only they know where its seams are.
fn planBatches(a: std.mem.Allocator, state_json: []const u8, qs: []const Question, items: []const []const u8, max_items: usize) ![]usize {
var bounds: std.ArrayListUnmanaged(usize) = .empty;
try bounds.append(a, 0);
const base = state_json.len + 64; // envelope: model, keys, braces
var used: usize = base;
var count: usize = 0;
for (items, 0..) |line, n| {
var cost: usize = 0;
var longest: usize = 0;
for (qs) |q| {
var bld = Build{ .a = a };
try appendQuestion(&bld, q, n, line);
cost += bld.b.items.len + 1;
longest = @max(longest, bld.b.items.len);
bld.b.deinit(a);
}
if (base + longest > MAX_ONE_QUESTION_BYTES or base + cost > MAX_REQUEST_BYTES) {
plan_bad_item = n;
plan_bad_bytes = base + cost;
return error.ItemTooLarge;
}
if (count > 0 and (used + cost > MAX_REQUEST_BYTES or count >= max_items)) {
try bounds.append(a, n);
used = base;
count = 0;
}
used += cost;
count += 1;
}
try bounds.append(a, items.len);
return bounds.toOwnedSlice(a);
}
/// `state` is already-serialised JSON (a string literal, or validated structure).
fn buildRequest(a: std.mem.Allocator, state_json: []const u8, model: []const u8, qs: []const Question, items: ?[]const []const u8) ![]u8 {
var bld = Build{ .a = a };
try bld.raw("{\"state\":");
// In -l mode the state is only the shared context (-s/-S), "" without one:
// each line travels in its own questions.
try bld.raw(if (items != null and std.mem.eql(u8, state_json, "null")) "\"\"" else state_json);
try bld.raw(",\"model\":");
try bld.plain(model);
try bld.raw(",\"questions\":{");
var first = true;
if (items) |its| {
for (its, 0..) |line, n| for (qs) |q| {
if (!first) try bld.raw(",");
first = false;
try appendQuestion(&bld, q, n, line);
};
} else for (qs) |q| {
if (!first) try bld.raw(",");
first = false;
try appendQuestion(&bld, q, null, null);
}
try bld.raw("}}");
return bld.b.toOwnedSlice(a);
}
/// State as a JSON value: structure passes through (validated), text is quoted.
fn stateJson(a: std.mem.Allocator, text: []const u8, force_text: bool) ![]u8 {
const t = std.mem.trim(u8, text, " \t\r\n");
if (!force_text and t.len > 0 and (t[0] == '{' or t[0] == '[')) {
if (std.json.validate(a, t) catch false) return a.dupe(u8, t);
}
var b: std.ArrayListUnmanaged(u8) = .empty;
try b.append(a, '"');
try feat.jsonEscape(&b, a, std.mem.trimEnd(u8, text, "\n"));
try b.append(a, '"');
return b.toOwnedSlice(a);
}
// ===========================================================================
// answers
// ===========================================================================
fn num(v: std.json.Value) ?f64 {
const f: f64 = switch (v) {
.float => |f| f,
.integer => |i| @floatFromInt(i),
.number_string => |s| std.fmt.parseFloat(f64, s) catch return null,
else => return null,
};
return if (std.math.isFinite(f)) f else null;
}
/// A probability, confidence or noul: a finite number in [0, 1].
fn unit(v: ?std.json.Value) ?f64 {
const f = num(v orelse return null) orelse return null;
return if (f >= 0 and f <= 1) f else null;
}
var bad_msg: []const u8 = "";
fn badAnswer(msg: []const u8) error{BadAnswer} {
bad_msg = msg;
return error.BadAnswer;
}
/// The answer to `q` under `key`, checked against what was asked. Nothing is
/// defaulted: a missing field, a type other than the question's, a number out
/// of range, or a choice the question did not offer is error.BadAnswer — exit
/// 2, "could not decide". A zero-initialised answer used to read as "no", and
/// `?<.5 safe?` then passed on a response that said nothing at all.
fn getAnswer(q: Question, answers: std.json.ObjectMap, key: []const u8) error{BadAnswer}!Answer {
const v = answers.get(key) orelse return badAnswer("missing");
const o = switch (v) {
.object => |ob| ob,
else => return badAnswer("not an object"),
};
const tname = switch (o.get("type") orelse return badAnswer("no type")) {
.string => |s| s,
else => return badAnswer("type is not a string"),
};
const kind = std.meta.stringToEnum(Kind, tname) orelse return badAnswer("unknown type");
// A -J question's kind is whatever its JSON said; check against that.
if (q.raw == null and kind != q.kind) return badAnswer("type differs from the question's");
var ans = Answer{ .kind = kind };
switch (kind) {
.noul => ans.value = unit(o.get("noul")) orelse return badAnswer("noul missing or outside [0,1]"),
.choice => {
ans.choice = switch (o.get("choice") orelse return badAnswer("no choice")) {
.string => |s| s,
else => return badAnswer("choice is not a string"),
};
if (q.raw == null) {
var offered = false;
for (q.alts) |al| {
if (std.mem.eql(u8, al.key, ans.choice)) offered = true;
}
if (!offered) return badAnswer("choice is not one of the options");
} else for (ans.choice) |c| if (c < 0x20 or c == 0x7f) return badAnswer("choice has control bytes");
ans.conf = unit(o.get("confidence")) orelse return badAnswer("confidence missing or outside [0,1]");
},
.score => {
ans.value = num(o.get("score") orelse return badAnswer("no score")) orelse return badAnswer("score is not a number");
if (q.raw == null) {
const top: f64 = @floatFromInt(q.alts.len - 1);
if (ans.value < 0 or ans.value > top) return badAnswer("score outside the levels");
}
ans.conf = unit(o.get("confidence")) orelse return badAnswer("confidence missing or outside [0,1]");
},
}
if (o.get("probabilities")) |p| switch (p) {
.object => |ob| {
var it = ob.iterator();
while (it.next()) |e| _ = unit(e.value_ptr.*) orelse return badAnswer("probability outside [0,1]");
ans.probs = ob;
},
else => return badAnswer("probabilities is not an object"),
};
return ans;
}
fn cmpNum(c: Cmp, a: f64, b: f64) bool {
return switch (c) {
.gt => a > b,
.ge => a >= b,
.lt => a < b,
.le => a <= b,
.eq => a == b,
.ne => a != b,
};
}
fn passes(q: Question, ans: Answer) bool {
const g = q.gate orelse return true;
if (g.conf) |c| if ((ans.conf orelse return false) < c) return false;
const cmp = g.cmp orelse return true;
return switch (q.kind) {
.choice => (std.mem.eql(u8, ans.choice, g.opt)) == (cmp == .eq),
else => cmpNum(cmp, ans.value, g.num),
};
}
/// `v` to DECIMALS places, trailing zeros and a bare point dropped.
fn numText(buf: []u8, v: f64) []const u8 {
const t = std.fmt.bufPrint(buf, std.fmt.comptimePrint("{{d:.{d}}}", .{DECIMALS}), .{v}) catch return "0";
var n = t.len;
if (std.mem.indexOfScalar(u8, t, '.') != null) {
while (t[n - 1] == '0') n -= 1;
if (t[n - 1] == '.') n -= 1;
}
// A tiny negative rounds to "-0"; no answer is negative, so print 0.
if (std.mem.eql(u8, t[0..n], "-0")) return "0";
return t[0..n];
}
fn fmtNum(b: *std.ArrayListUnmanaged(u8), a: std.mem.Allocator, v: f64) !void {
var nb: [64]u8 = undefined;
try b.appendSlice(a, numText(&nb, v));
}
/// The answer's value fields, space-separated, no key, no newline. Shaped by
/// the answer's kind, which for a -J question is the only kind there is.
fn formatAnswer(b: *std.ArrayListUnmanaged(u8), a: std.mem.Allocator, q: Question, ans: Answer, probs: bool) !void {
switch (ans.kind) {
.noul => try fmtNum(b, a, ans.value),
.choice => {
try b.appendSlice(a, ans.choice);
try b.append(a, ' ');
try fmtNum(b, a, ans.conf.?);
},
.score => {
try fmtNum(b, a, ans.value);
try b.append(a, ' ');
try fmtNum(b, a, ans.conf.?);
},
}
if (!probs or q.raw != null) return;
const p = ans.probs orelse return;
// In the order the question listed them: the API's map order is its own.
for (q.alts, 0..) |al, j| {
var kb: [8]u8 = undefined;
const k = if (q.kind == .score) std.fmt.bufPrint(&kb, "{d}", .{j}) catch continue else al.key;
const v = num(p.get(k) orelse continue) orelse continue;
try b.print(a, " {s}=", .{k});
try fmtNum(b, a, v);
}
}
// ===========================================================================
// transport: curl, or --mock
// ===========================================================================
const Reply = struct { status: u32, body: []const u8 };
var child_envp: [*:null]const ?[*:0]const u8 = @ptrCast(&[1]?[*:0]const u8{null});
/// Cap on a response body. Real answers are a few KB; this bounds a hostile
/// or broken endpoint, enforced both by curl (--max-filesize) and by the read.
const MAX_RESPONSE = 16 * 1024 * 1024;
fn sleepMs(ms: u64) void {
var ts: linux.timespec = .{ .sec = @intCast(ms / 1000), .nsec = @intCast((ms % 1000) * 1_000_000) };
_ = linux.nanosleep(&ts, &ts);
}
fn sysOk(rc: usize) bool {
return linux.errno(rc) == .SUCCESS;
}
/// An anonymous in-memory file holding `bytes`, close-on-exec. Null on failure.
fn memfd(name: [*:0]const u8, bytes: []const u8) ?i32 {
const rc = linux.memfd_create(name, linux.MFD.CLOEXEC);
if (!sysOk(rc)) return null;
const fd: i32 = @intCast(rc);
var off: usize = 0;
while (off < bytes.len) {
const w = linux.write(fd, bytes.ptr + off, bytes.len - off);
if (linux.errno(w) == .INTR) continue;
if (!sysOk(w) or w == 0) {
_ = linux.close(fd);
return null;
}
off += w;
}
return fd;
}
/// POST `body` via curl. Nothing touches the disk: the auth header (as a curl
/// config) and the body live in memfds that only the curl child can see, at
/// fds 3 and 4. There is no file to leak when this process is SIGKILLed, so
/// there is no cleanup to get wrong — the old ~/.zish temp files outlived
/// every kill, OOM and closed terminal with the bearer key inside.
///
/// curl is pinned down: `-q` first (no ~/.curlrc), https only, the URL via
/// --url (an endpoint beginning with `-` is not an option), no redirects
/// (curl's default; a redirect would carry the key), a size cap. Its exit
/// status is checked — `-w` alone cannot tell a timeout from an answer.
/// The --local socket, held to the key-file rule: a socket, not a symlink,
/// owned by you, and its directory owned by you and closed to everyone else
/// (no group or other bits). Ownership is what stands in for authenticating
/// the server: no other user can create a file you own, and no other user can
/// put one in a 0700 directory of yours.
fn checkSocket(a: std.mem.Allocator, p: []const u8) error{ SocketUnsafe, OutOfMemory }!void {
if (!std.fs.path.isAbsolute(p)) return sockErr(p, "is not an absolute path");
const mask: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true };
var sx: linux.Statx = undefined;
const pz = try a.dupeZ(u8, p);
switch (linux.errno(linux.statx(linux.AT.FDCWD, pz, linux.AT.SYMLINK_NOFOLLOW, mask, &sx))) {
.SUCCESS => {},
.NOENT, .NOTDIR => return sockErr(p, "does not exist: is the local server running?"),
else => return sockErr(p, "cannot be checked"),
}
if (sx.mode & linux.S.IFMT == linux.S.IFLNK) return sockErr(p, "is a symlink");
if (sx.mode & linux.S.IFMT != linux.S.IFSOCK) return sockErr(p, "is not a socket");
if (sx.uid != linux.getuid()) return sockErr(p, "is not owned by you");
const dir = try a.dupeZ(u8, std.fs.path.dirname(p) orelse "/");
switch (linux.errno(linux.statx(linux.AT.FDCWD, dir, linux.AT.SYMLINK_NOFOLLOW, mask, &sx))) {
.SUCCESS => {},
else => return sockErr(dir, "cannot be checked"),
}
if (sx.mode & linux.S.IFMT != linux.S.IFDIR) return sockErr(dir, "is not a directory (a symlink?)");
if (sx.uid != linux.getuid()) return sockErr(dir, "is not owned by you");
if (sx.mode & 0o077 != 0) return sockErr(dir, "is open to others (chmod 700)");
}
var sock_msg: []const u8 = "";
fn sockErr(path: []const u8, why: []const u8) error{SocketUnsafe} {
sock_msg = std.fmt.allocPrint(std.heap.page_allocator, "{s} {s}", .{ path, why }) catch why;
return error.SocketUnsafe;
}
/// With `socket` (--local, already checked) the request goes over that Unix
/// socket as plain http and carries no key at all.
fn fetchCurl(a: std.mem.Allocator, endpoint: []const u8, socket: ?[]const u8, key: []const u8, body: []const u8) ?Reply {
const cfg_text = if (key.len == 0) "" else std.fmt.allocPrint(a, "header = \"Authorization: Bearer {s}\"\n", .{key}) catch return null;
const cfg_fd = memfd("jevx-cfg", cfg_text) orelse return null;
defer _ = linux.close(cfg_fd);
const body_fd = memfd("jevx-body", body) orelse return null;
defer _ = linux.close(body_fd);
const url = a.dupeZ(u8, endpoint) catch return null;
var av: std.ArrayListUnmanaged(?[*:0]const u8) = .empty;
av.appendSlice(a, &.{
"env", "curl",
"-q", "-sS",
"--proto", if (socket != null) "=http" else "=https",
"--max-time", "60",
"--max-filesize", "16777216",
"-K", "/dev/fd/3",
"-H", "Content-Type: application/json",
"--data-binary", "@/dev/fd/4",
"-w", "\n%{http_code}",
}) catch return null;
if (socket) |sp| {
const spz = a.dupeZ(u8, sp) catch return null;
av.appendSlice(a, &.{ "--unix-socket", spz.ptr }) catch return null;
}
av.appendSlice(a, &.{ "--url", url.ptr, null }) catch return null;
const argv: [*:null]const ?[*:0]const u8 = @ptrCast(av.items.ptr);
var fds: [2]i32 = undefined;
if (!sysOk(linux.pipe2(&fds, .{ .CLOEXEC = true }))) return null;
const fork_rc = linux.fork();
if (!sysOk(fork_rc)) {
_ = linux.close(fds[0]);
_ = linux.close(fds[1]);
return null;
}
const pid: linux.pid_t = @intCast(fork_rc);
if (pid == 0) {
// Through high fds first: a source fd may itself be 1, 3 or 4, and a
// direct dup2 onto it would clobber the other.
if (!sysOk(linux.dup2(fds[1], 200)) or !sysOk(linux.dup2(cfg_fd, 201)) or
!sysOk(linux.dup2(body_fd, 202)) or !sysOk(linux.dup2(200, 1)) or
!sysOk(linux.dup2(201, 3)) or !sysOk(linux.dup2(202, 4))) linux.exit(127);
_ = linux.close(200);
_ = linux.close(201);
_ = linux.close(202);
_ = linux.execve("/usr/bin/env", argv, child_envp);
linux.exit(127);
}
_ = linux.close(fds[1]);
var buf: std.ArrayListUnmanaged(u8) = .empty;
var tmp: [8192]u8 = undefined;
var overflow = false;
while (true) {