@visual-z/xeno 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,103 @@
1
+ /** O.2: identity and lifecycle are explicit; only command evidence can verify completion. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import type { Evidence } from "./handoff.ts";
4
+ import { isCommandResult, resolveEvidence, evidenceStale } from "./evidence.ts";
5
+
6
+ export type WorkItem = {
7
+ id: string;
8
+ what: string;
9
+ status: "active" | "blocked" | "completed" | "cancelled";
10
+ verification: "not_run" | "verified" | "unverified" | "stale";
11
+ opened_at: string;
12
+ updated_at: string;
13
+ next_action?: string;
14
+ reason?: string;
15
+ evidence?: Evidence;
16
+ /** Reopening keeps provenance without carrying a second copy of old proof. */
17
+ previous_evidence?: string;
18
+ };
19
+
20
+ /** O.2: sibling sessions may choose the same local label without aliasing work. */
21
+ export function scopedWorkId(namespace: string, requested: string, items: readonly WorkItem[]): string {
22
+ return items.some(item => item.id === requested) || requested.startsWith(`${namespace}:`)
23
+ ? requested : `${namespace}:${requested}`;
24
+ }
25
+
26
+ export function workChangeError(items: readonly WorkItem[], event: LogEvent): string | null {
27
+ if (!("work_id" in event) || event.work_id === undefined) return null;
28
+ const item = items.find(item => item.id === event.work_id);
29
+ if (event.t === "work_status") {
30
+ // D15 carries post-fork instructions, not their whole history. A harness-
31
+ // authenticated transition may seed intent in the adopted branch, never proof.
32
+ if (!item && !event.carried_from) return `unknown work_id ${event.work_id}; create it with a next note first`;
33
+ if (!item) return event.operation !== "cancel" && !event.next_action?.trim()
34
+ ? "reactivating work requires a current next_action" : null;
35
+ if (event.operation === "unblock" && item.status !== "blocked") return `work ${item.id} is not blocked`;
36
+ if (event.operation === "reopen" && item.status !== "completed" && item.status !== "cancelled") {
37
+ return `work ${item.id} is not closed; update its next action or unblock it`;
38
+ }
39
+ if (event.operation !== "cancel" && !event.next_action?.trim()) return "reactivating work requires a current next_action";
40
+ } else if (event.t === "claim" && !item) {
41
+ return `unknown work_id ${event.work_id}; record a next note before doing and verifying the work`;
42
+ } else if (item && (item.status === "completed" || item.status === "cancelled")) {
43
+ return `work ${item.id} is ${item.status}; explicitly reopen it before updating it`;
44
+ } else if (event.t === "claim" && item?.status === "blocked") {
45
+ return `work ${item.id} is blocked; explicitly unblock it before completing it`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function deriveWorkItems(previous: readonly WorkItem[], events: readonly LogEvent[]): WorkItem[] {
51
+ const items = new Map(previous.map(item => [item.id, { ...item }]));
52
+ for (let index = 0; index < events.length; index++) {
53
+ const event = events[index]!;
54
+ if (!("work_id" in event) || event.work_id === undefined || workChangeError([...items.values()], event)) continue;
55
+ const old = items.get(event.work_id);
56
+ const item: WorkItem = old ? { ...old, what: event.what, updated_at: event.id } : {
57
+ id: event.work_id, what: event.what, status: "active", verification: "not_run",
58
+ opened_at: event.id, updated_at: event.id,
59
+ };
60
+ if (event.t === "next_action") item.next_action = event.next_action;
61
+ else if (event.t === "blocked") {
62
+ item.status = "blocked";
63
+ item.reason = event.reason;
64
+ } else if (event.t === "work_status") {
65
+ if (event.operation === "cancel") {
66
+ item.status = "cancelled";
67
+ item.reason = event.reason ?? "cancelled";
68
+ delete item.next_action;
69
+ } else {
70
+ item.status = "active";
71
+ item.next_action = event.next_action!;
72
+ delete item.reason;
73
+ if (event.operation === "reopen") {
74
+ item.opened_at = event.id;
75
+ item.verification = "not_run";
76
+ if (item.evidence) item.previous_evidence = item.evidence.event_id;
77
+ delete item.evidence;
78
+ }
79
+ }
80
+ } else if (event.t === "claim") {
81
+ // Preserve D11's same-segment rule and require proof from this activation.
82
+ const resolved = resolveEvidence(events.slice(0, index), event.evidence, item.opened_at);
83
+ if (resolved.ok) {
84
+ item.status = "completed";
85
+ item.verification = "verified";
86
+ item.evidence = resolved.evidence;
87
+ delete item.reason;
88
+ delete item.next_action;
89
+ } else { item.verification = "unverified"; item.reason = resolved.reason; }
90
+ }
91
+ items.set(item.id, item);
92
+ }
93
+ return [...items.values()].map(item => {
94
+ if (!item.evidence) return item;
95
+ const proof = item.evidence;
96
+ const later = events.slice(events.findIndex(event => event.id === proof.event_id) + 1);
97
+ const latest = later.findLast(event => isCommandResult(event) &&
98
+ (proof.gate ? event.effect.gate === proof.gate : event.effect.cmd === proof.command));
99
+ const stale = evidenceStale(events, proof, item.verification === "stale") ||
100
+ (latest !== undefined && isCommandResult(latest) && latest.effect.exit !== 0);
101
+ return { ...item, verification: stale ? "stale" : "verified" };
102
+ });
103
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Restricted, deterministic YAML emitter.
3
+ *
4
+ * Why hand-written instead of a dependency: YAML emitters differ in indentation,
5
+ * quoting and line-wrapping defaults, so the usual advice is "pick one writer and
6
+ * pin its version". A restricted emitter removes the version-drift risk entirely --
7
+ * the format is fixed by construction, not by someone else's defaults.
8
+ *
9
+ * Guarantees:
10
+ * - Block style only (the sole exception is `[]` / `{}` for empty collections).
11
+ * - Indent 2, LF endings, exactly one trailing newline, no trailing whitespace.
12
+ * - No anchors or aliases, so no topological-ordering hazard.
13
+ * - Key order comes from an explicit `keyOrder` resolver; unlisted keys are
14
+ * appended in sorted order so a new field can never silently vanish.
15
+ * - Byte-identical output for equal input: emit(x) === emit(structuredClone(x)).
16
+ *
17
+ * Supported value types: string, finite number, boolean, null, array, plain object.
18
+ * `undefined` values are omitted (that is how optional fields disappear); `null` is
19
+ * emitted explicitly, so "absent" and "present but empty" stay distinguishable.
20
+ */
21
+
22
+ export type YamlValue =
23
+ | string
24
+ | number
25
+ | boolean
26
+ | null
27
+ | undefined
28
+ | readonly YamlValue[]
29
+ | { readonly [key: string]: YamlValue };
30
+
31
+ export interface EmitOptions {
32
+ /**
33
+ * Returns the preferred key order for the object at `path`, or undefined to
34
+ * sort alphabetically. Keys returned but absent from the object are skipped;
35
+ * keys present but unlisted are appended in sorted order.
36
+ */
37
+ keyOrder?: (path: readonly string[]) => readonly string[] | undefined;
38
+ }
39
+
40
+ const INDENT = " ";
41
+
42
+ /**
43
+ * Short all-scalar arrays render in flow style (`[1, 120]`). Nested block
44
+ * sequences are legal but read badly -- a line range would otherwise print as
45
+ * "- - 1" / " - 120" -- and this file is meant to be reviewed by a person.
46
+ * The rule is a fixed predicate, so output stays deterministic.
47
+ */
48
+ const FLOW_MAX_WIDTH = 60;
49
+
50
+ /** YAML 1.1-era tokens that a bare scalar must not collide with. */
51
+ const RESERVED_PLAIN =
52
+ /^(|~|null|Null|NULL|true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF|y|Y|n|N)$/;
53
+
54
+ const NUMBER_LIKE =
55
+ /^[+-]?(\d[\d_]*\.?[\d_]*|\.[\d_]+)([eE][+-]?\d+)?$|^[+-]?0[xXoObB][0-9a-fA-F_]+$|^[+-]?\.(inf|Inf|INF)$|^\.(nan|NaN|NAN)$/;
56
+
57
+ /** Leading characters that carry structural meaning in YAML. */
58
+ const LEADING_INDICATOR = new Set([
59
+ "-", "?", ":", ",", "[", "]", "{", "}", "#", "&", "*", "!",
60
+ "|", ">", "'", '"', "%", "@", "`",
61
+ ]);
62
+
63
+ function hasControlChars(s: string): boolean {
64
+ for (const ch of s) {
65
+ const c = ch.codePointAt(0)!;
66
+ // Allow \n (handled by the block-scalar path); reject every other control char.
67
+ if ((c < 0x20 && c !== 0x0a) || c === 0x7f) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ function isPlainSafe(s: string): boolean {
73
+ if (s.length === 0) return false;
74
+ if (s !== s.trim()) return false;
75
+ if (s.includes("\n") || hasControlChars(s)) return false;
76
+ if (LEADING_INDICATOR.has(s[0]!)) return false;
77
+ if (RESERVED_PLAIN.test(s) || NUMBER_LIKE.test(s)) return false;
78
+ // ": " opens a mapping; " #" opens a comment; a trailing ":" also reads as a key.
79
+ if (s.includes(": ") || s.includes(" #") || s.endsWith(":")) return false;
80
+ return true;
81
+ }
82
+
83
+ function quoteDouble(s: string): string {
84
+ let out = '"';
85
+ for (const ch of s) {
86
+ const c = ch.codePointAt(0)!;
87
+ if (ch === '"') out += '\\"';
88
+ else if (ch === "\\") out += "\\\\";
89
+ else if (ch === "\n") out += "\\n";
90
+ else if (ch === "\t") out += "\\t";
91
+ else if (ch === "\r") out += "\\r";
92
+ else if (c < 0x20 || c === 0x7f) out += "\\x" + c.toString(16).padStart(2, "0");
93
+ else out += ch;
94
+ }
95
+ return out + '"';
96
+ }
97
+
98
+ /**
99
+ * A multi-line string is only emitted as a block scalar when every line is clean.
100
+ * Leading whitespace on the first line would need an explicit indentation
101
+ * indicator, and trailing whitespace on any line would violate the
102
+ * no-trailing-whitespace guarantee -- both fall back to a double-quoted scalar,
103
+ * which is always correct if less pretty.
104
+ */
105
+ function canUseBlockScalar(s: string): boolean {
106
+ if (!s.includes("\n")) return false;
107
+ if (hasControlChars(s)) return false;
108
+ // `|+` (keep) is the most ambiguous corner of the spec -- parsers disagree about
109
+ // trailing blank lines at EOF. Two or more trailing newlines therefore fall back
110
+ // to a quoted scalar rather than relying on that behaviour.
111
+ if (/\n\n$/.test(s)) return false;
112
+ const lines = s.split("\n");
113
+ const firstNonEmpty = lines.find((l) => l.length > 0);
114
+ if (firstNonEmpty !== undefined && /^\s/.test(firstNonEmpty)) return false;
115
+ for (const line of lines) {
116
+ if (line.length > 0 && line !== line.replace(/\s+$/, "")) return false;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ function blockScalar(s: string, indent: string): string {
122
+ // canUseBlockScalar() guarantees at most one trailing newline here.
123
+ const trailing = /\n*$/.exec(s)![0].length;
124
+ const header = trailing === 0 ? "|-" : "|";
125
+ const body = trailing === 0 ? s : s.slice(0, -1);
126
+ const inner = indent + INDENT;
127
+ const lines = body.split("\n").map((l) => (l.length === 0 ? "" : inner + l));
128
+ return header + "\n" + lines.join("\n");
129
+ }
130
+
131
+ function scalar(v: string | number | boolean | null, indent: string): string {
132
+ if (v === null) return "null";
133
+ if (typeof v === "boolean") return v ? "true" : "false";
134
+ if (typeof v === "number") {
135
+ if (!Number.isFinite(v)) {
136
+ throw new TypeError(`yaml: non-finite number is not emittable: ${v}`);
137
+ }
138
+ // Number#toString is the shortest round-trippable form, hence deterministic.
139
+ // String(-0) drops the sign, so spell it out.
140
+ return Object.is(v, -0) ? "-0" : String(v);
141
+ }
142
+ if (canUseBlockScalar(v)) return blockScalar(v, indent);
143
+ if (isPlainSafe(v)) return v;
144
+ return quoteDouble(v);
145
+ }
146
+
147
+ function isPlainObject(v: unknown): v is { readonly [key: string]: YamlValue } {
148
+ return typeof v === "object" && v !== null && !Array.isArray(v);
149
+ }
150
+
151
+ type Scalar = string | number | boolean | null;
152
+
153
+ function isScalar(v: YamlValue): v is Scalar {
154
+ return v === null || ["string", "number", "boolean"].includes(typeof v);
155
+ }
156
+
157
+ /**
158
+ * Returns the flow rendering (`[a, b]`) when every element is a scalar that needs
159
+ * no block form and the result fits FLOW_MAX_WIDTH; otherwise undefined, and the
160
+ * caller falls back to block style.
161
+ */
162
+ function tryFlow(items: readonly YamlValue[]): string | undefined {
163
+ if (items.length === 0) return undefined;
164
+ if (!items.every(isScalar)) return undefined;
165
+ const parts: string[] = [];
166
+ for (const item of items as readonly Scalar[]) {
167
+ if (typeof item === "string" && item.includes("\n")) return undefined;
168
+ parts.push(scalar(item, ""));
169
+ }
170
+ const text = "[" + parts.join(", ") + "]";
171
+ return text.length <= FLOW_MAX_WIDTH ? text : undefined;
172
+ }
173
+
174
+ function orderedKeys(
175
+ obj: { readonly [key: string]: YamlValue },
176
+ path: readonly string[],
177
+ opts: EmitOptions,
178
+ ): string[] {
179
+ const present = Object.keys(obj).filter((k) => obj[k] !== undefined);
180
+ const preferred = opts.keyOrder?.(path);
181
+ if (!preferred) return present.sort();
182
+ const set = new Set(present);
183
+ const ordered = preferred.filter((k) => set.has(k));
184
+ const rest = present.filter((k) => !preferred.includes(k)).sort();
185
+ return [...ordered, ...rest];
186
+ }
187
+
188
+ function emitKey(k: string): string {
189
+ return isPlainSafe(k) ? k : quoteDouble(k);
190
+ }
191
+
192
+ function emitNode(
193
+ v: YamlValue,
194
+ indent: string,
195
+ path: readonly string[],
196
+ opts: EmitOptions,
197
+ lines: string[],
198
+ ): void {
199
+ if (Array.isArray(v)) {
200
+ const items = v.filter((x) => x !== undefined);
201
+ if (items.length === 0) {
202
+ lines[lines.length - 1] += " []";
203
+ return;
204
+ }
205
+ for (const item of items) {
206
+ if (Array.isArray(item)) {
207
+ const flow = tryFlow(item);
208
+ if (flow !== undefined) {
209
+ lines.push(indent + "- " + flow);
210
+ continue;
211
+ }
212
+ }
213
+ if (Array.isArray(item) || isPlainObject(item)) {
214
+ const before = lines.length;
215
+ lines.push(indent + "-");
216
+ emitNode(item, indent + INDENT, path, opts, lines);
217
+ // Hoist the first child onto the dash line: "- key: value".
218
+ const dashLine = lines[before]!;
219
+ const firstChild = lines[before + 1];
220
+ if (firstChild !== undefined && dashLine === indent + "-") {
221
+ lines.splice(before, 2, indent + "- " + firstChild.slice(indent.length + INDENT.length));
222
+ }
223
+ } else {
224
+ lines.push(indent + "- " + scalar(item as string | number | boolean | null, indent));
225
+ }
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (isPlainObject(v)) {
231
+ const keys = orderedKeys(v, path, opts);
232
+ if (keys.length === 0) {
233
+ lines[lines.length - 1] += " {}";
234
+ return;
235
+ }
236
+ for (const k of keys) {
237
+ const child = v[k]!;
238
+ const childPath = [...path, k];
239
+ if (Array.isArray(child)) {
240
+ const items = child.filter((x) => x !== undefined);
241
+ const flow = tryFlow(items);
242
+ if (items.length === 0) {
243
+ lines.push(indent + emitKey(k) + ": []");
244
+ } else if (flow !== undefined) {
245
+ lines.push(indent + emitKey(k) + ": " + flow);
246
+ } else {
247
+ lines.push(indent + emitKey(k) + ":");
248
+ // Indent sequences under their key; valid either way, but readable.
249
+ emitNode(child, indent + INDENT, childPath, opts, lines);
250
+ }
251
+ } else if (isPlainObject(child)) {
252
+ if (orderedKeys(child, childPath, opts).length === 0) {
253
+ lines.push(indent + emitKey(k) + ": {}");
254
+ } else {
255
+ lines.push(indent + emitKey(k) + ":");
256
+ emitNode(child, indent + INDENT, childPath, opts, lines);
257
+ }
258
+ } else {
259
+ lines.push(
260
+ indent + emitKey(k) + ": " +
261
+ scalar(child as string | number | boolean | null, indent),
262
+ );
263
+ }
264
+ }
265
+ return;
266
+ }
267
+
268
+ lines.push(indent + scalar(v as string | number | boolean | null, indent));
269
+ }
270
+
271
+ /** Emit `value` as deterministic block-style YAML with a single trailing newline. */
272
+ export function emitYaml(value: YamlValue, opts: EmitOptions = {}): string {
273
+ if (value === undefined) return "";
274
+ const lines: string[] = [];
275
+ if (Array.isArray(value) || isPlainObject(value)) {
276
+ emitNode(value, "", [], opts, lines);
277
+ } else {
278
+ lines.push(scalar(value as string | number | boolean | null, ""));
279
+ }
280
+ if (lines.length === 0) return "{}\n";
281
+ return lines.join("\n") + "\n";
282
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./core/events.ts";
2
+ export * from "./core/handoff.ts";
3
+ export * from "./core/derive.ts";
4
+ export * from "./core/project.ts";
5
+ export * from "./core/boundary.ts";
6
+ export * from "./core/permissions.ts";
7
+ export * from "./core/tokens.ts";
8
+ export { assertNever } from "./core/exhaustive.ts";
9
+ export * from "./store/log.ts";
10
+ export { emitYaml, type YamlValue, type EmitOptions } from "./core/yaml.ts";
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Where a credential comes from.
3
+ *
4
+ * The difficulty here is entirely in the priority, not in the reading, so the
5
+ * reading is injected: `env` and `run` are parameters. That is what lets the
6
+ * one rule that actually bites be pinned by a test that touches no
7
+ * environment and no keychain --
8
+ *
9
+ * A STORED CREDENTIAL OWNS ITS PROVIDER. The environment is consulted only
10
+ * when nothing is stored, and never as a fallback after a stored entry
11
+ * fails to resolve. Otherwise someone edits `auth.json`, sees no change,
12
+ * and has no way to discover that a forgotten `export` from months ago is
13
+ * still winning. pi states this rule outright in its resolver; it is worth
14
+ * copying verbatim.
15
+ *
16
+ * The order is pi's: command line, then `auth.json`, then the environment.
17
+ * A fourth position belongs to custom providers declared in `models.json`,
18
+ * which this build does not have yet.
19
+ */
20
+
21
+ import { assertNever } from "../core/exhaustive.ts";
22
+ import type { ModelSpec } from "./model.ts";
23
+ import {
24
+ refreshOAuthCredential,
25
+ type OAuthCredential,
26
+ type RefreshEffects,
27
+ } from "./oauth.ts";
28
+
29
+ export type KeyValue =
30
+ | { kind: "literal"; value: string }
31
+ | { kind: "command"; command: string }
32
+ | { kind: "interpolate"; template: string };
33
+
34
+ export type StoredCredential =
35
+ | { type: "api_key"; key: string }
36
+ | OAuthCredential;
37
+
38
+ export interface AuthFile {
39
+ [provider: string]: StoredCredential | undefined;
40
+ }
41
+
42
+ export function storedIsOAuth(stored: AuthFile, provider: string): boolean {
43
+ const entry = stored[provider];
44
+ return entry !== undefined && entry.type === "oauth";
45
+ }
46
+
47
+ export type Resolved =
48
+ | { ok: true; key: string; source: "flag" | "auth.json" | "environment" }
49
+ | { ok: false; reason: string };
50
+
51
+ /** The two effects resolution needs, injected so the rules stay testable. */
52
+ export interface ResolveEffects {
53
+ env(name: string): string | undefined;
54
+ run(command: string): string;
55
+ }
56
+
57
+ /**
58
+ * Which of the three forms a configured key value is.
59
+ *
60
+ * A plain uppercase string is a LITERAL, not a variable name. That choice
61
+ * matters: a provider whose key really is `public` or `ollama` is common, and
62
+ * guessing that an uppercase word means a lookup would break those silently.
63
+ * `$` marks a lookup, and nothing else does.
64
+ */
65
+ export function parseKeyValue(raw: string): KeyValue {
66
+ if (raw.startsWith("!")) return { kind: "command", command: raw.slice(1) };
67
+ // The escapes are checked before interpolation so a key that genuinely
68
+ // begins with `$` or `!` can be written down at all.
69
+ if (raw.startsWith("$$")) return { kind: "literal", value: `$${raw.slice(2)}` };
70
+ if (raw.startsWith("$!")) return { kind: "literal", value: `!${raw.slice(2)}` };
71
+ if (raw.includes("$")) return { kind: "interpolate", template: raw };
72
+ return { kind: "literal", value: raw };
73
+ }
74
+
75
+ /** `$FOO_BAR` is the variable FOO_BAR; `${FOO}_BAR` is FOO followed by literal text. */
76
+ const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
77
+
78
+ export function resolveKeyValue(
79
+ raw: string,
80
+ fx: ResolveEffects,
81
+ ): { ok: true; key: string } | { ok: false; reason: string } {
82
+ const parsed = parseKeyValue(raw);
83
+ switch (parsed.kind) {
84
+ case "literal":
85
+ return { ok: true, key: parsed.value };
86
+
87
+ case "command": {
88
+ let out: string;
89
+ try {
90
+ out = fx.run(parsed.command);
91
+ } catch (err) {
92
+ return { ok: false, reason: `key command failed: ${(err as Error).message}` };
93
+ }
94
+ // Every CLI that prints a secret prints a newline after it, and a key
95
+ // with a trailing newline is rejected by the API as malformed.
96
+ const key = out.trim();
97
+ if (key.length === 0) {
98
+ return { ok: false, reason: `key command produced no output: ${parsed.command}` };
99
+ }
100
+ return { ok: true, key };
101
+ }
102
+
103
+ case "interpolate": {
104
+ const missing: string[] = [];
105
+ const key = parsed.template.replace(VARIABLE, (_whole, braced?: string, bare?: string) => {
106
+ const name = braced ?? bare ?? "";
107
+ const value = fx.env(name);
108
+ if (value === undefined || value.length === 0) {
109
+ missing.push(name);
110
+ return "";
111
+ }
112
+ return value;
113
+ });
114
+ // Unresolved is NOT the empty string. An empty key looks legal, gets
115
+ // sent, and comes back as a 401 that says nothing about the real
116
+ // mistake -- which is that a variable was never exported.
117
+ if (missing.length > 0) {
118
+ return { ok: false, reason: `unset environment variable: ${missing.join(", ")}` };
119
+ }
120
+ return { ok: true, key };
121
+ }
122
+
123
+ default: {
124
+ const never: never = parsed;
125
+ throw new Error(`unreachable key value: ${JSON.stringify(never)}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ export function resolveCredential(
131
+ spec: ModelSpec,
132
+ stored: AuthFile,
133
+ flagKey: string | undefined,
134
+ fx: ResolveEffects,
135
+ ): Resolved {
136
+ if (flagKey !== undefined && flagKey.length > 0) {
137
+ const got = resolveKeyValue(flagKey, fx);
138
+ return got.ok ? { ok: true, key: got.key, source: "flag" } : got;
139
+ }
140
+
141
+ const entry = stored[spec.provider];
142
+ if (entry !== undefined) {
143
+ // Deliberately no fallback to the environment on failure. See the header.
144
+ if (entry.type === "oauth") {
145
+ return { ok: true, key: entry.access, source: "auth.json" };
146
+ }
147
+ if (entry.type === "api_key") {
148
+ const got = resolveKeyValue(entry.key, fx);
149
+ return got.ok
150
+ ? { ok: true, key: got.key, source: "auth.json" }
151
+ : { ok: false, reason: `${spec.provider} in auth.json: ${got.reason}` };
152
+ }
153
+ return assertNever(entry, "stored credential");
154
+ }
155
+
156
+ for (const variable of envVarsOf(spec.provider)) {
157
+ const value = fx.env(variable);
158
+ if (value !== undefined && value.length > 0) {
159
+ return { ok: true, key: value, source: "environment" };
160
+ }
161
+ }
162
+
163
+ return { ok: false, reason: describeMissingCredential(spec.provider) };
164
+ }
165
+
166
+ /**
167
+ * Environment variable each provider reads. Same names pi uses, so a machine
168
+ * already set up for one works for the other without re-exporting anything.
169
+ */
170
+ export const ENV_VAR: Readonly<Record<string, string | readonly string[]>> = {
171
+ anthropic: "ANTHROPIC_API_KEY",
172
+ openai: "OPENAI_API_KEY",
173
+ google: ["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
174
+ deepseek: "DEEPSEEK_API_KEY",
175
+ xai: "XAI_API_KEY",
176
+ groq: "GROQ_API_KEY",
177
+ cerebras: "CEREBRAS_API_KEY",
178
+ mistral: "MISTRAL_API_KEY",
179
+ openrouter: "OPENROUTER_API_KEY",
180
+ zai: "ZHIPU_API_KEY",
181
+ moonshotai: "MOONSHOT_API_KEY",
182
+ together: "TOGETHER_API_KEY",
183
+ };
184
+
185
+ export function envVarsOf(provider: string): readonly string[] {
186
+ const v = ENV_VAR[provider];
187
+ // A provider name is free text in a user catalog; an environment variable
188
+ // is not. `kimi-coding` must become KIMI_CODING, or no shell can set it.
189
+ if (v === undefined) return [`${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`];
190
+ return typeof v === "string" ? [v] : v;
191
+ }
192
+
193
+ export function envLabel(provider: string): string {
194
+ return envVarsOf(provider).join(" or ");
195
+ }
196
+
197
+ /**
198
+ * What to say when there is no credential.
199
+ *
200
+ * Both routes, named concretely. The failure a person hits on first run
201
+ * should carry its own fix; sending them to read the docs for something this
202
+ * small is the difference between trying the tool and closing it.
203
+ */
204
+ export function describeMissingCredential(provider: string): string {
205
+ return (
206
+ `no credential for ${provider}. Either export ${envLabel(provider)}, ` +
207
+ `or run /login ${provider} to store one in ~/.xeno/auth.json`
208
+ );
209
+ }
210
+
211
+ export interface FreshEffects extends RefreshEffects {
212
+ persist: (auth: AuthFile) => Promise<void>;
213
+ }
214
+
215
+ /**
216
+ * Resolve, refreshing an expired OAuth access token first.
217
+ *
218
+ * A dead refresh fails here and names `/login <provider>`. It does not fall
219
+ * through to the environment: that is the same rule as a stored API key that
220
+ * cannot resolve. Otherwise a forgotten export quietly starts billing a
221
+ * different account.
222
+ */
223
+ export async function ensureFreshCredential(
224
+ spec: ModelSpec,
225
+ stored: AuthFile,
226
+ flagKey: string | undefined,
227
+ fx: ResolveEffects,
228
+ oauth: FreshEffects,
229
+ ): Promise<Resolved> {
230
+ if (flagKey !== undefined && flagKey.length > 0) {
231
+ return resolveCredential(spec, stored, flagKey, fx);
232
+ }
233
+ const entry = stored[spec.provider];
234
+ if (entry !== undefined && entry.type === "oauth") {
235
+ if (oauth.now() < entry.expires) {
236
+ return { ok: true, key: entry.access, source: "auth.json" };
237
+ }
238
+ const refreshOpts: RefreshEffects = {
239
+ fetch: oauth.fetch,
240
+ now: oauth.now,
241
+ };
242
+ if (oauth.signal !== undefined) refreshOpts.signal = oauth.signal;
243
+ const refreshed = await refreshOAuthCredential(spec.provider, entry, refreshOpts);
244
+ if (!refreshed.ok) {
245
+ return {
246
+ ok: false,
247
+ reason: `${spec.provider} OAuth expired. Run /login ${spec.provider} to sign in again.`,
248
+ };
249
+ }
250
+ const next: AuthFile = { ...stored, [spec.provider]: refreshed.credential };
251
+ await oauth.persist(next);
252
+ return { ok: true, key: refreshed.credential.access, source: "auth.json" };
253
+ }
254
+ return resolveCredential(spec, stored, flagKey, fx);
255
+ }