@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.
- package/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request usage accounting.
|
|
3
|
+
*
|
|
4
|
+
* This exists to answer the question the whole design rests on: is the prompt
|
|
5
|
+
* prefix actually stable? The core's claim is that appending within a node
|
|
6
|
+
* only appends, so every turn should read the entire accumulated prefix from
|
|
7
|
+
* cache and write only what the last turn added. That is checkable, and the
|
|
8
|
+
* shape of a healthy loop is specific:
|
|
9
|
+
*
|
|
10
|
+
* cache_read grows turn over turn and covers the whole prior prefix
|
|
11
|
+
* cache_creation is small -- only the delta
|
|
12
|
+
* input_tokens (uncached) stays near zero after the first turn
|
|
13
|
+
*
|
|
14
|
+
* A cache_read that sits at zero across turns with the same prefix means a
|
|
15
|
+
* silent invalidator got in. Since that failure costs money without ever
|
|
16
|
+
* raising an error, measuring it is the only way to know.
|
|
17
|
+
*
|
|
18
|
+
* Pure: the aggregator takes samples and returns totals. The caller records
|
|
19
|
+
* the timestamps.
|
|
20
|
+
*
|
|
21
|
+
* The field names below are a NORMALISED internal vocabulary, not one
|
|
22
|
+
* provider's wire format. Every adapter maps onto them: a provider that bills
|
|
23
|
+
* nothing for cache writes reports 0 there rather than leaving the field to
|
|
24
|
+
* mean something else.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { CacheKind, CacheRates, Price } from "./model.ts";
|
|
28
|
+
|
|
29
|
+
/** What one API response reports, normalised by the adapter. */
|
|
30
|
+
export interface UsageSample {
|
|
31
|
+
input_tokens: number;
|
|
32
|
+
output_tokens: number;
|
|
33
|
+
cache_creation_input_tokens: number;
|
|
34
|
+
cache_read_input_tokens: number;
|
|
35
|
+
/** Which node the request belonged to, so per-node costs stay separable. */
|
|
36
|
+
node: number;
|
|
37
|
+
/** Wall-clock milliseconds for the request. */
|
|
38
|
+
ms: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UsageTotals {
|
|
42
|
+
requests: number;
|
|
43
|
+
input_tokens: number;
|
|
44
|
+
output_tokens: number;
|
|
45
|
+
cache_creation_input_tokens: number;
|
|
46
|
+
cache_read_input_tokens: number;
|
|
47
|
+
ms: number;
|
|
48
|
+
/**
|
|
49
|
+
* Total prompt size is the sum of all three input fields -- `input_tokens`
|
|
50
|
+
* alone is only the uncached remainder. An agent that ran for hours can show
|
|
51
|
+
* 4K there while the real prefix was 200K.
|
|
52
|
+
*/
|
|
53
|
+
prompt_tokens: number;
|
|
54
|
+
/**
|
|
55
|
+
* Fraction of prompt tokens served from cache. The number that says whether
|
|
56
|
+
* the append-only property is holding in practice.
|
|
57
|
+
*/
|
|
58
|
+
cache_hit_rate: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function emptyTotals(): UsageTotals {
|
|
62
|
+
return {
|
|
63
|
+
requests: 0,
|
|
64
|
+
input_tokens: 0,
|
|
65
|
+
output_tokens: 0,
|
|
66
|
+
cache_creation_input_tokens: 0,
|
|
67
|
+
cache_read_input_tokens: 0,
|
|
68
|
+
ms: 0,
|
|
69
|
+
prompt_tokens: 0,
|
|
70
|
+
cache_hit_rate: 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
|
|
75
|
+
const t = emptyTotals();
|
|
76
|
+
for (const s of samples) {
|
|
77
|
+
t.requests += 1;
|
|
78
|
+
t.input_tokens += s.input_tokens;
|
|
79
|
+
t.output_tokens += s.output_tokens;
|
|
80
|
+
t.cache_creation_input_tokens += s.cache_creation_input_tokens;
|
|
81
|
+
t.cache_read_input_tokens += s.cache_read_input_tokens;
|
|
82
|
+
t.ms += s.ms;
|
|
83
|
+
}
|
|
84
|
+
t.prompt_tokens =
|
|
85
|
+
t.input_tokens + t.cache_creation_input_tokens + t.cache_read_input_tokens;
|
|
86
|
+
t.cache_hit_rate =
|
|
87
|
+
t.prompt_tokens === 0 ? 0 : t.cache_read_input_tokens / t.prompt_tokens;
|
|
88
|
+
return t;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cost in equivalent base-input tokens, the unit appendix A's model uses.
|
|
93
|
+
*
|
|
94
|
+
* Reported as a multiplier rather than dollars so it stays true across models
|
|
95
|
+
* and price changes. The rates come from the model rather than from this
|
|
96
|
+
* module: they are not a property of the unit, they are a property of the
|
|
97
|
+
* provider's contract, and they differ by an order of magnitude. Anthropic
|
|
98
|
+
* bills reads at 0.1x and 5-minute writes at 1.25x; DeepSeek bills reads at
|
|
99
|
+
* 0.033x and writes at nothing. Output is counted separately because its rate
|
|
100
|
+
* is a different multiple of input and varies by model.
|
|
101
|
+
*/
|
|
102
|
+
export interface EquivalentCost {
|
|
103
|
+
input_equivalent: number;
|
|
104
|
+
output_tokens: number;
|
|
105
|
+
/** What the same prompts would have cost with no caching at all. */
|
|
106
|
+
uncached_equivalent: number;
|
|
107
|
+
/** uncached / actual. 1.0 means caching bought nothing. */
|
|
108
|
+
savings_ratio: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Dollar cost of a session: complete, partial, or an explicit unknown.
|
|
113
|
+
*
|
|
114
|
+
* Unknown is a distinct member, not the number 0: a missing price times any
|
|
115
|
+
* token count is not "this session was free", and collapsing the two is how
|
|
116
|
+
* `/usage` would silently misreport every unpriced model.
|
|
117
|
+
*
|
|
118
|
+
* Partial is a distinct member, not `{kind:"usd", amount, note}`. A caller
|
|
119
|
+
* that only reads `amount` on `usd` must not be able to print a total that
|
|
120
|
+
* silently omitted cache spend as if the unknown rate were zero. `known`
|
|
121
|
+
* is the part we can prove; `uncounted` is why it is not a complete bill.
|
|
122
|
+
*/
|
|
123
|
+
export type ReportedCost =
|
|
124
|
+
| { kind: "unknown" }
|
|
125
|
+
| { kind: "usd"; amount: number }
|
|
126
|
+
| { kind: "partial"; known: number; uncounted: "cache" };
|
|
127
|
+
|
|
128
|
+
export function reportedCost(t: UsageTotals, price: Price | undefined): ReportedCost {
|
|
129
|
+
if (price === undefined) return { kind: "unknown" };
|
|
130
|
+
const known =
|
|
131
|
+
(t.input_tokens * price.input + t.output_tokens * price.output) / 1_000_000;
|
|
132
|
+
if (price.cacheRead !== undefined && price.cacheWrite !== undefined) {
|
|
133
|
+
const amount =
|
|
134
|
+
known +
|
|
135
|
+
(t.cache_read_input_tokens * price.cacheRead +
|
|
136
|
+
t.cache_creation_input_tokens * price.cacheWrite) /
|
|
137
|
+
1_000_000;
|
|
138
|
+
return { kind: "usd", amount };
|
|
139
|
+
}
|
|
140
|
+
// One or both cache rates are missing. Add the ones we have; never
|
|
141
|
+
// substitute 0 for the ones we do not — that is the low-bill path.
|
|
142
|
+
let extra = 0;
|
|
143
|
+
if (price.cacheRead !== undefined) {
|
|
144
|
+
extra += (t.cache_read_input_tokens * price.cacheRead) / 1_000_000;
|
|
145
|
+
}
|
|
146
|
+
if (price.cacheWrite !== undefined) {
|
|
147
|
+
extra += (t.cache_creation_input_tokens * price.cacheWrite) / 1_000_000;
|
|
148
|
+
}
|
|
149
|
+
return { kind: "partial", known: known + extra, uncounted: "cache" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function equivalentCost(t: UsageTotals, rates: CacheRates): EquivalentCost {
|
|
153
|
+
const actual =
|
|
154
|
+
t.input_tokens +
|
|
155
|
+
t.cache_creation_input_tokens * rates.write +
|
|
156
|
+
t.cache_read_input_tokens * rates.read;
|
|
157
|
+
const uncached = t.prompt_tokens;
|
|
158
|
+
return {
|
|
159
|
+
input_equivalent: actual,
|
|
160
|
+
output_tokens: t.output_tokens,
|
|
161
|
+
uncached_equivalent: uncached,
|
|
162
|
+
savings_ratio: actual === 0 ? 1 : uncached / actual,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Per-turn diagnosis of whether the prefix held.
|
|
168
|
+
*
|
|
169
|
+
* `cache_creation` above the delta means something ahead of the new content
|
|
170
|
+
* changed, which is the signature of an invalidator rather than of growth.
|
|
171
|
+
*
|
|
172
|
+
* What counts as a symptom depends on who controls the cache. Under `explicit`
|
|
173
|
+
* caching the caller placed the breakpoint, so a miss on a follow-up request
|
|
174
|
+
* means the prefix moved and that is worth shouting about. Under `automatic`
|
|
175
|
+
* caching the provider decides: DeepSeek documents its cache as best-effort
|
|
176
|
+
* with no guaranteed hit rate and writes that "take seconds", so an early miss
|
|
177
|
+
* is the normal case. Reporting it would light a warning at the start of every
|
|
178
|
+
* session, and a warning that is always on stops being read.
|
|
179
|
+
*/
|
|
180
|
+
export type PrefixVerdict =
|
|
181
|
+
| { kind: "cold"; detail: string }
|
|
182
|
+
| { kind: "healthy"; read: number; wrote: number }
|
|
183
|
+
| { kind: "suspicious"; detail: string };
|
|
184
|
+
|
|
185
|
+
export function diagnosePrefix(
|
|
186
|
+
sample: UsageSample,
|
|
187
|
+
previous: UsageSample | undefined,
|
|
188
|
+
cache: CacheKind,
|
|
189
|
+
): PrefixVerdict {
|
|
190
|
+
if (!previous) {
|
|
191
|
+
return { kind: "cold", detail: "first request of the session; nothing to reuse yet" };
|
|
192
|
+
}
|
|
193
|
+
if (sample.cache_read_input_tokens === 0) {
|
|
194
|
+
return cache === "automatic"
|
|
195
|
+
? {
|
|
196
|
+
kind: "cold",
|
|
197
|
+
detail:
|
|
198
|
+
"no cache hit yet -- this provider caches on its own, best-effort, and " +
|
|
199
|
+
"a write takes seconds to land. Only a problem if it persists across turns",
|
|
200
|
+
}
|
|
201
|
+
: {
|
|
202
|
+
kind: "suspicious",
|
|
203
|
+
detail:
|
|
204
|
+
"cache_read is 0 on a follow-up request -- the prefix changed, or it is " +
|
|
205
|
+
"below the model's minimum cacheable size",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const previousPrompt =
|
|
209
|
+
previous.input_tokens +
|
|
210
|
+
previous.cache_creation_input_tokens +
|
|
211
|
+
previous.cache_read_input_tokens;
|
|
212
|
+
if (sample.cache_read_input_tokens < previousPrompt * 0.5) {
|
|
213
|
+
return {
|
|
214
|
+
kind: "suspicious",
|
|
215
|
+
detail:
|
|
216
|
+
`cache_read (${sample.cache_read_input_tokens}) covers less than half of the ` +
|
|
217
|
+
`previous prompt (${previousPrompt}) -- part of the prefix was invalidated`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
kind: "healthy",
|
|
222
|
+
read: sample.cache_read_input_tokens,
|
|
223
|
+
wrote: sample.cache_creation_input_tokens,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** N.13: real sessions with independent file verdicts at every stage. */
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { verifyWindowArtifact, windowPrompt, type WindowFixture } from "../core/window-trial.ts";
|
|
6
|
+
import type { BenchmarkPrice } from "../core/benchmark.ts";
|
|
7
|
+
import { projectContext } from "../core/project.ts";
|
|
8
|
+
import { readNodeHistory } from "../store/restore.ts";
|
|
9
|
+
import { manifestPath, nodePath } from "../store/log.ts";
|
|
10
|
+
import { Session, type SessionConfig } from "./loop.ts";
|
|
11
|
+
import { buildRequest, OpenAICompletionsProvider } from "./openai.ts";
|
|
12
|
+
import type { Provider, ProviderConfig } from "./provider.ts";
|
|
13
|
+
import { COMPARISON_SYSTEM, COMPARISON_TOOLS, comparisonGateway, comparisonPayload, comparisonTools, type ComparisonBackend, type ComparisonLedger, type GatewayRecord } from "./harness-comparison.ts";
|
|
14
|
+
import { DEFAULT_TOOL_CONTEXT } from "./tools.ts";
|
|
15
|
+
|
|
16
|
+
const sha = (body: string) => createHash("sha256").update(body).digest("hex");
|
|
17
|
+
export interface WindowStageResult {
|
|
18
|
+
stage: number; status: "answered" | "stopped" | "balance" | "error"; reason: string;
|
|
19
|
+
artifact: { ok: boolean; reason: string }; artifactSha256: string | null;
|
|
20
|
+
fullPageRead: boolean; inputsUnchanged: boolean; passed: boolean;
|
|
21
|
+
lastEvent: string | null; inputs: Record<string, string | null>;
|
|
22
|
+
requestBegin: number; requestEnd: number; nodeBefore: number; nodeAfter: number;
|
|
23
|
+
restart: { beforeSha256: string; afterSha256: string } | null;
|
|
24
|
+
}
|
|
25
|
+
export interface WindowTrialResult {
|
|
26
|
+
policy: "windowed" | "continuous"; planned: number; stages: WindowStageResult[]; unrun: number;
|
|
27
|
+
cuts: number; firstCutStage: number | null; restartedAt: number | null;
|
|
28
|
+
requests: number; knownCost: number; reservedUnknown: number; peakPrompt: number;
|
|
29
|
+
promptTokens: number; cacheTokens: number; outputTokens: number; prefixMutationsWithinNode: number;
|
|
30
|
+
toolExecutions: number; toolFailures: number; wallMs: number;
|
|
31
|
+
stopped: "budget" | "balance" | "error" | "limit" | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runWindowTrial(options: {
|
|
35
|
+
directory: string; fixture: WindowFixture; policy: WindowTrialResult["policy"];
|
|
36
|
+
config: ProviderConfig; price: BenchmarkPrice; ledger: ComparisonLedger; backend: ComparisonBackend;
|
|
37
|
+
maxTurns: number; stageTimeoutMs: number; maxOutputBytes: number;
|
|
38
|
+
/** windowed restarts after its first cut; continuous uses that same stage. */
|
|
39
|
+
restartAfterStage?: number | null;
|
|
40
|
+
onProgress?: (event: Record<string, unknown>) => Promise<void>;
|
|
41
|
+
}): Promise<WindowTrialResult> {
|
|
42
|
+
const started = performance.now(), workspace = join(options.directory, "workspace"), logDir = join(options.directory, "log");
|
|
43
|
+
await mkdir(workspace, { recursive: true }); await mkdir(logDir);
|
|
44
|
+
const result: WindowTrialResult = { policy: options.policy, planned: options.fixture.pages.length, stages: [], unrun: options.fixture.pages.length,
|
|
45
|
+
cuts: 0, firstCutStage: null, restartedAt: null, requests: 0, knownCost: 0, reservedUnknown: 0, peakPrompt: 0,
|
|
46
|
+
promptTokens: 0, cacheTokens: 0, outputTokens: 0, prefixMutationsWithinNode: 0, toolExecutions: 0, toolFailures: 0, wallMs: 0, stopped: null };
|
|
47
|
+
let stage = 0, lastNode: number | null = null, sentNode = 0;
|
|
48
|
+
const gateway = comparisonGateway({ config: options.config, price: options.price, ledger: options.ledger,
|
|
49
|
+
maxRequests: options.maxTurns * options.fixture.pages.length, backend: options.backend, record: async record => {
|
|
50
|
+
await appendFile(join(options.directory, "requests.jsonl"), JSON.stringify({ stage, node: sentNode, ...record }) + "\n");
|
|
51
|
+
if (record.kind === "settled") {
|
|
52
|
+
result.requests++;
|
|
53
|
+
if (record.measured === null) result.reservedUnknown += record.reservation; else result.knownCost += record.measured;
|
|
54
|
+
const usage = record.usage;
|
|
55
|
+
if (usage) {
|
|
56
|
+
const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
|
|
57
|
+
result.peakPrompt = Math.max(result.peakPrompt, prompt); result.promptTokens += prompt;
|
|
58
|
+
result.cacheTokens += usage.cache_read_input_tokens; result.outputTokens += usage.output_tokens;
|
|
59
|
+
}
|
|
60
|
+
if (lastNode === sentNode && record.prefixUnchanged === false) result.prefixMutationsWithinNode++;
|
|
61
|
+
lastNode = sentNode;
|
|
62
|
+
}
|
|
63
|
+
await options.onProgress?.({ phase: record.kind, policy: options.policy, stage, request: record.request,
|
|
64
|
+
measured: record.measured, failure: record.failure, result });
|
|
65
|
+
} });
|
|
66
|
+
const config = { ...options.config, spec: { ...options.config.spec, baseUrl: gateway.url } };
|
|
67
|
+
const actual = new OpenAICompletionsProvider(config, gateway.token, { retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
|
|
68
|
+
const sessionConfig: SessionConfig = { logDir, blobDir: join(options.directory, "blobs"),
|
|
69
|
+
frozen: { system: COMPARISON_SYSTEM, tools: comparisonTools() },
|
|
70
|
+
tools: { ...DEFAULT_TOOL_CONTEXT, root: workspace, allow: COMPARISON_TOOLS, maxOutputBytes: options.maxOutputBytes,
|
|
71
|
+
outputStore: join(options.directory, "observations") }, contextPolicy: options.policy, maxTurns: options.maxTurns, harnessReviews: false };
|
|
72
|
+
const provider: Provider = { config, async send(frozen, messages, node, signal, onDelta) {
|
|
73
|
+
sentNode = node;
|
|
74
|
+
await appendFile(join(options.directory, "dispatch.jsonl"), JSON.stringify({ stage, request: gateway.requests + 1, node,
|
|
75
|
+
lastEvent: session.state.events.at(-1)?.id,
|
|
76
|
+
inputSha256: sha(JSON.stringify(comparisonPayload(config, buildRequest(config, frozen, messages).messages, true))) }) + "\n");
|
|
77
|
+
return actual.send(frozen, messages, node, signal, onDelta);
|
|
78
|
+
} };
|
|
79
|
+
let session = new Session(sessionConfig, provider);
|
|
80
|
+
try {
|
|
81
|
+
await session.start();
|
|
82
|
+
for (const page of options.fixture.pages) {
|
|
83
|
+
if (options.ledger.halted) break;
|
|
84
|
+
stage = page.stage;
|
|
85
|
+
const file = join(workspace, page.path); await mkdir(dirname(file), { recursive: true });
|
|
86
|
+
await writeFile(file, page.body, { flag: "wx" });
|
|
87
|
+
const row: WindowStageResult = { stage, status: "stopped", reason: "no completion", artifact: { ok: false, reason: "not checked" },
|
|
88
|
+
artifactSha256: null, fullPageRead: false, inputsUnchanged: false, passed: false, lastEvent: null, inputs: {}, requestBegin: gateway.requests,
|
|
89
|
+
requestEnd: gateway.requests, nodeBefore: session.state.node, nodeAfter: session.state.node, restart: null };
|
|
90
|
+
const signal = AbortSignal.timeout(options.stageTimeoutMs);
|
|
91
|
+
try {
|
|
92
|
+
for await (const event of session.send(windowPrompt(options.fixture, stage), signal)) {
|
|
93
|
+
if (event.kind === "cut") { result.cuts++; result.firstCutStage ??= stage; }
|
|
94
|
+
if (event.kind === "tool_call") result.toolExecutions++;
|
|
95
|
+
if (event.kind === "tool_result" && event.ok === false) result.toolFailures++;
|
|
96
|
+
if (event.kind === "done") row.reason = event.reason ?? "unknown completion";
|
|
97
|
+
}
|
|
98
|
+
if (["stop", "end_turn"].includes(row.reason)) row.status = "answered";
|
|
99
|
+
} catch {
|
|
100
|
+
row.status = gateway.stopped === "balance" ? "balance" : "error";
|
|
101
|
+
row.reason = signal.aborted ? "stage deadline" : gateway.stopped === "balance" ? "upstream balance; awaiting topup" : "trial stopped on error";
|
|
102
|
+
}
|
|
103
|
+
row.nodeAfter = session.state.node; row.requestEnd = gateway.requests;
|
|
104
|
+
const body = await readFile(join(workspace, "ledger.json"), "utf8").catch(() => null);
|
|
105
|
+
row.artifact = verifyWindowArtifact(body, page.expected); row.artifactSha256 = body === null ? null : sha(body);
|
|
106
|
+
// Preserve failed outputs as well, before the next stage can repair them.
|
|
107
|
+
await writeFile(join(options.directory, `artifact-${stage}.json`), JSON.stringify({ body }), { flag: "wx" });
|
|
108
|
+
const history = await readNodeHistory(logDir, session.state.node, undefined, "context");
|
|
109
|
+
if (!history.ok) throw new Error(history.error);
|
|
110
|
+
const events = history.nodes.flatMap(node => node.events);
|
|
111
|
+
row.lastEvent = events.at(-1)?.id ?? null;
|
|
112
|
+
row.fullPageRead = events.some(event => event.t === "tool_result" && event.ok && event.effect?.kind === "read" &&
|
|
113
|
+
event.effect.path === page.path && event.effect.sha256 === sha(page.body) && event.effect.ranges?.some(range => range[0] === 1 && range[1] === page.body.split("\n").length) &&
|
|
114
|
+
event.content_ref === undefined && event.content.includes(page.body.split("\n").at(-1)!));
|
|
115
|
+
for (const prior of options.fixture.pages.slice(0, stage + 1)) {
|
|
116
|
+
const content = await readFile(join(workspace, prior.path), "utf8").catch(() => null);
|
|
117
|
+
row.inputs[prior.path] = content === null ? null : sha(content);
|
|
118
|
+
}
|
|
119
|
+
row.inputsUnchanged = options.fixture.pages.slice(0, stage + 1).every(prior => row.inputs[prior.path] === sha(prior.body));
|
|
120
|
+
row.passed = row.status === "answered" && row.artifact.ok && row.fullPageRead && row.inputsUnchanged;
|
|
121
|
+
const restart = options.policy === "windowed" ? result.firstCutStage : options.restartAfterStage;
|
|
122
|
+
if (row.status === "answered" && result.restartedAt === null && restart === stage) {
|
|
123
|
+
const view = () => sha(JSON.stringify(projectContext({ frozen: session.config.frozen, handoff: session.state.handoff, events: session.state.events,
|
|
124
|
+
logHint: { manifestPath: manifestPath(logDir), segmentPath: nodePath(logDir, Math.max(0, session.state.node - 1)) } })));
|
|
125
|
+
const beforeSha256 = view(); session = new Session(sessionConfig, provider); await session.start();
|
|
126
|
+
row.restart = { beforeSha256, afterSha256: view() }; result.restartedAt = stage;
|
|
127
|
+
if (row.restart.beforeSha256 !== row.restart.afterSha256) throw new Error("restart changed the context projection");
|
|
128
|
+
}
|
|
129
|
+
result.stages.push(row); result.unrun = result.planned - result.stages.length;
|
|
130
|
+
await writeFile(join(options.directory, `stage-${stage}.json`), JSON.stringify(row, null, 2) + "\n", { flag: "wx" });
|
|
131
|
+
await options.onProgress?.({ phase: "stage", policy: options.policy, stage, result });
|
|
132
|
+
if (row.status !== "answered") break;
|
|
133
|
+
}
|
|
134
|
+
} finally { await gateway.close(); }
|
|
135
|
+
result.stopped = gateway.stopped; result.wallMs = performance.now() - started;
|
|
136
|
+
await writeFile(join(options.directory, "result.json"), JSON.stringify(result, null, 2) + "\n", { flag: "wx" });
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type WindowRequest = GatewayRecord & { stage: number; node: number };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** O.2/O.6: validate annotations against the full projection before they can be appended. */
|
|
2
|
+
import type { LogEvent } from "../core/events.ts";
|
|
3
|
+
import { deriveHandoff } from "../core/derive.ts";
|
|
4
|
+
import { scopedWorkId, workChangeError } from "../core/work-state.ts";
|
|
5
|
+
import { replayContext } from "../store/replay.ts";
|
|
6
|
+
import { digestNode, nodePath } from "../store/log.ts";
|
|
7
|
+
import { resolveEvidenceNote } from "./evidence-notes.ts";
|
|
8
|
+
import type { ToolResult } from "./tools.ts";
|
|
9
|
+
|
|
10
|
+
export async function prepareWorkingNote(logDir: string, node: number, namespace: string, annotation: LogEvent | undefined, out: ToolResult): Promise<{ out: ToolResult; annotation?: LogEvent }> {
|
|
11
|
+
if (!annotation) return { out };
|
|
12
|
+
if (annotation.t !== "evidence_link" && !("work_id" in annotation && annotation.work_id !== undefined)) return { out, annotation };
|
|
13
|
+
const replayed = await replayContext(logDir, node);
|
|
14
|
+
const input = { prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
|
|
15
|
+
self: { node, log_ref: nodePath(logDir, node), sha256: await digestNode(logDir, node) } };
|
|
16
|
+
const current = deriveHandoff(input);
|
|
17
|
+
if (annotation.t === "evidence_link") {
|
|
18
|
+
const resolved = await resolveEvidenceNote(logDir, node, namespace, current, annotation);
|
|
19
|
+
return { out: resolved.result, ...(resolved.event ? { annotation: resolved.event } : {}) };
|
|
20
|
+
}
|
|
21
|
+
if (!("work_id" in annotation) || annotation.work_id === undefined) throw new Error("missing identified work annotation");
|
|
22
|
+
const workId = scopedWorkId(namespace, annotation.work_id, current.state.work_items ?? []);
|
|
23
|
+
annotation = { ...annotation, work_id: workId };
|
|
24
|
+
const error = workChangeError(current.state.work_items ?? [], annotation);
|
|
25
|
+
if (error) return { out: { ok: false, content: error } };
|
|
26
|
+
const proposed = deriveHandoff({ ...input, events: [...input.events, annotation] });
|
|
27
|
+
const item = proposed.state.work_items?.find(item => item.id === workId);
|
|
28
|
+
return { annotation, out: { ok: annotation.t !== "claim" || item?.verification === "verified",
|
|
29
|
+
content: JSON.stringify({ recorded: true, work_item: item }) } };
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Durable replacement for small derived metadata (D3/D8). */
|
|
2
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, basename } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export async function atomicWrite(path: string, content: string | Uint8Array): Promise<void> {
|
|
7
|
+
const directory = dirname(path);
|
|
8
|
+
await mkdir(directory, { recursive: true });
|
|
9
|
+
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
|
|
10
|
+
try {
|
|
11
|
+
const file = await open(temporary, "wx", 0o600);
|
|
12
|
+
try {
|
|
13
|
+
await file.writeFile(content);
|
|
14
|
+
await file.sync();
|
|
15
|
+
} finally { await file.close(); }
|
|
16
|
+
await rename(temporary, path);
|
|
17
|
+
const folder = await open(directory, "r");
|
|
18
|
+
try { await folder.sync(); } finally { await folder.close(); }
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(temporary, { force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The record must reach durable storage before its side effect is dispatched. */
|
|
25
|
+
export async function durableAppend(path: string, record: string): Promise<void> {
|
|
26
|
+
const file = await open(path, "a", 0o600);
|
|
27
|
+
try { await file.writeFile(record); await file.sync(); }
|
|
28
|
+
finally { await file.close(); }
|
|
29
|
+
const directory = await open(dirname(path), "r");
|
|
30
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-modify-write for `auth.json`.
|
|
3
|
+
*
|
|
4
|
+
* The file holds API keys and OAuth tokens under one lock so a refresh cannot
|
|
5
|
+
* clobber a login that landed between the read and the write. 0600 is applied
|
|
6
|
+
* on every write -- an older world-readable file would otherwise keep leaking
|
|
7
|
+
* after we started storing refresh tokens in it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { authPath, readJsonFile, writeJsonFile } from "./config.ts";
|
|
11
|
+
|
|
12
|
+
export type StoredAuthEntry =
|
|
13
|
+
| { type: "api_key"; key: string }
|
|
14
|
+
| {
|
|
15
|
+
type: "oauth";
|
|
16
|
+
access: string;
|
|
17
|
+
refresh: string;
|
|
18
|
+
expires: number;
|
|
19
|
+
accountId?: string;
|
|
20
|
+
enterpriseUrl?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AuthFileContents = {
|
|
24
|
+
[provider: string]: StoredAuthEntry | undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function readAuthFile(): Promise<AuthFileContents> {
|
|
28
|
+
const got = await readJsonFile<AuthFileContents>(authPath());
|
|
29
|
+
return got.value ?? {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeAuthFile(auth: AuthFileContents): Promise<void> {
|
|
33
|
+
await writeJsonFile(authPath(), auth, true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function upsertCredential(
|
|
37
|
+
provider: string,
|
|
38
|
+
credential: StoredAuthEntry,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const current = await readAuthFile();
|
|
41
|
+
const next: AuthFileContents = { ...current, [provider]: credential };
|
|
42
|
+
await writeAuthFile(next);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function deleteCredential(provider: string): Promise<void> {
|
|
46
|
+
const current = await readAuthFile();
|
|
47
|
+
const next: AuthFileContents = { ...current };
|
|
48
|
+
delete next[provider];
|
|
49
|
+
await writeAuthFile(next);
|
|
50
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed blob store.
|
|
3
|
+
*
|
|
4
|
+
* A directory of sha256-named files, sharded two hex digits deep so no single
|
|
5
|
+
* directory holds every blob. Writes are immutable: the same bytes always land
|
|
6
|
+
* at the same address, so storing a file twice costs nothing and a stored blob
|
|
7
|
+
* is never modified in place.
|
|
8
|
+
*
|
|
9
|
+
* Why not git's object database, which is exactly this and comes free with
|
|
10
|
+
* every repository: git's lifetime contract is REACHABILITY. A blob with no
|
|
11
|
+
* ref pointing at it is by definition unreachable, which is precisely what
|
|
12
|
+
* `git gc` deletes -- and gc runs automatically on pull, merge, rebase and
|
|
13
|
+
* commit. The default two-week grace period would be the only thing between a
|
|
14
|
+
* restore and data loss, and a write-heavy store also trips git's
|
|
15
|
+
* "too many unreachable loose objects" warning, after which git refuses
|
|
16
|
+
* further automatic cleanup until its log is cleared by hand. Keeping a ref
|
|
17
|
+
* per blob is more work than this file, and it would tie the lifetime of our
|
|
18
|
+
* data to the user's git operations.
|
|
19
|
+
*
|
|
20
|
+
* What that costs: no packing and no delta compression, so a large file
|
|
21
|
+
* edited many times is stored in full each time. Acceptable while the
|
|
22
|
+
* granularity is a turn rather than a keystroke, and revisitable behind this
|
|
23
|
+
* interface if it stops being.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFile, access, mkdir, open, rename, rm, copyFile, stat, chmod } from "node:fs/promises";
|
|
27
|
+
import { createReadStream } from "node:fs";
|
|
28
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
29
|
+
import { join, dirname } from "node:path";
|
|
30
|
+
import { atomicWrite } from "./atomic.ts";
|
|
31
|
+
|
|
32
|
+
export function blobAddress(content: string | Uint8Array): string {
|
|
33
|
+
return createHash("sha256").update(content).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Hash and archive in bounded chunks, so large ordinary files need no cap. */
|
|
37
|
+
export async function digestFile(path: string): Promise<string> {
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk as Uint8Array);
|
|
40
|
+
return hash.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function putFileBlob(dir: string, source: string): Promise<string> {
|
|
44
|
+
await mkdir(dir, { recursive: true });
|
|
45
|
+
const temp = join(dir, `.${randomUUID()}.blob`);
|
|
46
|
+
const file = await open(temp, "wx", 0o600);
|
|
47
|
+
const hash = createHash("sha256");
|
|
48
|
+
try {
|
|
49
|
+
try {
|
|
50
|
+
for await (const chunk of createReadStream(source)) {
|
|
51
|
+
const bytes = chunk as Uint8Array;
|
|
52
|
+
hash.update(bytes);
|
|
53
|
+
await file.writeFile(bytes);
|
|
54
|
+
}
|
|
55
|
+
await file.sync();
|
|
56
|
+
} finally { await file.close(); }
|
|
57
|
+
const sha = hash.digest("hex");
|
|
58
|
+
const dest = blobFilePath(dir, sha);
|
|
59
|
+
if (await hasBlob(dir, sha)) {
|
|
60
|
+
if (await digestFile(dest) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
61
|
+
return sha;
|
|
62
|
+
}
|
|
63
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
64
|
+
await rename(temp, dest);
|
|
65
|
+
const parent = await open(dirname(dest), "r");
|
|
66
|
+
try { await parent.sync(); } finally { await parent.close(); }
|
|
67
|
+
return sha;
|
|
68
|
+
} finally { await rm(temp, { force: true }); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function verifyBlob(dir: string, sha: string): Promise<boolean> {
|
|
72
|
+
try {
|
|
73
|
+
if (await digestFile(blobFilePath(dir, sha)) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if ((error as { code?: string }).code === "ENOENT") return false;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Stage before replacing; never expose half of a large restored file. */
|
|
82
|
+
export async function restoreBlob(dir: string, sha: string, destination: string): Promise<void> {
|
|
83
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
84
|
+
const temp = `${destination}.${randomUUID()}.restore`;
|
|
85
|
+
try {
|
|
86
|
+
await copyFile(blobFilePath(dir, sha), temp);
|
|
87
|
+
if (await digestFile(temp) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
88
|
+
try { await chmod(temp, (await stat(destination)).mode); }
|
|
89
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
90
|
+
const file = await open(temp, "r");
|
|
91
|
+
try { await file.sync(); } finally { await file.close(); }
|
|
92
|
+
await rename(temp, destination);
|
|
93
|
+
} finally { await rm(temp, { force: true }); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function blobFilePath(dir: string, sha: string): string {
|
|
97
|
+
if (!/^[a-f0-9]{64}$/.test(sha)) throw new Error("invalid blob digest");
|
|
98
|
+
// Two-digit shard: a flat directory of tens of thousands of files is slow
|
|
99
|
+
// to list on most filesystems, and listing is how a human inspects this.
|
|
100
|
+
return join(dir, sha.slice(0, 2), sha.slice(2));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stores content and returns its address. Storing the same bytes twice is free. */
|
|
104
|
+
export async function putBlob(dir: string, content: string | Uint8Array): Promise<string> {
|
|
105
|
+
const sha = blobAddress(content);
|
|
106
|
+
const path = blobFilePath(dir, sha);
|
|
107
|
+
// Content addressing means an existing blob already holds these exact
|
|
108
|
+
// bytes, so rewriting it could only turn a good file into a torn one.
|
|
109
|
+
if (await hasBlob(dir, sha)) {
|
|
110
|
+
await getBlobBytes(dir, sha);
|
|
111
|
+
return sha;
|
|
112
|
+
}
|
|
113
|
+
await atomicWrite(path, content);
|
|
114
|
+
return sha;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Null rather than throwing: a missing blob is a state callers must handle. */
|
|
118
|
+
export async function getBlob(dir: string, sha: string): Promise<string | null> {
|
|
119
|
+
const bytes = await getBlobBytes(dir, sha);
|
|
120
|
+
return bytes === null ? null : Buffer.from(bytes).toString("utf8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Restore uses bytes; decoding binary data is an irreversible transformation. */
|
|
124
|
+
export async function getBlobBytes(dir: string, sha: string): Promise<Uint8Array | null> {
|
|
125
|
+
try {
|
|
126
|
+
const bytes = await readFile(blobFilePath(dir, sha));
|
|
127
|
+
if (blobAddress(bytes) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
128
|
+
return bytes;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if ((err as { code?: string }).code === "ENOENT") return null;
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function hasBlob(dir: string, sha: string): Promise<boolean> {
|
|
136
|
+
try {
|
|
137
|
+
await access(blobFilePath(dir, sha));
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|