@fungi.computer/zookeeper 0.0.0-stage → 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # Zookeeper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Zookeeper governs one Durable Object's store. Storage and memory limits for
4
+ Durable Objects belong here, never in Shiitake core.
5
+
6
+ Zookeeper is an installable package candidate because Oyster's public Shiitake
7
+ host uses its pressure check and patrol at runtime. Its stranger fixture checks
8
+ the packed exports without workspace links. This packaging boundary does not
9
+ authorize an external repository, registry publication, or deployment.
10
+
11
+ `openStoragePressure({ usedBytes, budgetBytes })` returns the host's
12
+ `StoragePressureCheck` for Shiitake's `storagePressure` option. It reports
13
+ pressure at 80% of the budget and keeps reporting it until usage falls below
14
+ 75%, so admission does not flap at the threshold. Shiitake keeps the refusal
15
+ door and the typed `storage_pressure` error; Zookeeper owns the numbers.
16
+
17
+ An invalid budget throws a `contract`/`invalid` `ShiitakeError` when opened. An
18
+ unreadable measurement throws a `store`/`unavailable` `ShiitakeError`, which
19
+ refuses the new work.
20
+
21
+ Zookeeper is Shiitake-only. Pi Durable hosts are out of scope.
22
+
23
+ `openPatrol({ budget, readColdSessions, report })` returns host work for
24
+ Shiitake's `hostWork`. Its synchronous `pending` reads only the platform's size
25
+ of the store: `nextWakeAt` is `null` while the store is under the waterline
26
+ (half the budget) and a time at least a second ahead (five minutes by default)
27
+ while it is over. Its `run` is one bounded pass: when the store is over the
28
+ waterline it reads one page of Sessions, skips a Session whose bytes are unknown
29
+ or whose row cannot be read, and hands the host a typed `PatrolReport`. It
30
+ evicts nothing.
31
+
32
+ `zookeeperCompaction({ writer })` is the compaction handoff extension over a
33
+ host-bound `ArchiveWriter`; the host appends it and `zookeeperCompactionGrant`
34
+ to its roster. It archives what leaves the model context (the history and
35
+ turn-prefix messages, not the summary), returns the writer's receipt as the
36
+ opaque `accepted` receipt, and answers `discarded_by_policy` only when nothing
37
+ leaves the context. A writer failure is a handoff failure, so compaction stops
38
+ with the prior context intact; it is never a discard.
@@ -0,0 +1,23 @@
1
+ import type { BorrowedCompactionViewV0 } from "@fungi.computer/shiitake";
2
+ import type { ArchiveWriteV1 } from "./archive-writer.js";
3
+ /** Format name written in the first line of every archive. */
4
+ export declare const ARCHIVE_FORMAT = "zookeeper.compaction-archive/1";
5
+ /** Largest chunk a writer is handed. */
6
+ export declare const ARCHIVE_CHUNK_BYTES: number;
7
+ /** The part of a borrowed compaction view that leaves the model context. */
8
+ export type ArchiveSource = Pick<BorrowedCompactionViewV0, "contextRevision" | "messagesToSummarize" | "turnPrefixMessages"> & Readonly<{
9
+ firstKeptEntryId: string;
10
+ }>;
11
+ /** Size and identity of an archive; carries no bytes. */
12
+ export type ArchiveMeasure = Readonly<{
13
+ byteLength: number;
14
+ sha256: string;
15
+ materialKey: string;
16
+ }>;
17
+ /** Pass 1: hash and count the lines one at a time, retaining none. */
18
+ export declare function measureArchive(lines: Iterable<Uint8Array>): ArchiveMeasure;
19
+ /**
20
+ * Prepare one archive write: pass 1 measures, and `chunks()` is pass 2,
21
+ * re-encoding on demand so no full copy is ever held.
22
+ */
23
+ export declare function prepareArchiveWrite(sessionId: string, source: ArchiveSource): ArchiveWriteV1;
@@ -0,0 +1,70 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { bytesToHex } from "@noble/hashes/utils.js";
3
+ /** Format name written in the first line of every archive. */
4
+ export const ARCHIVE_FORMAT = "zookeeper.compaction-archive/1";
5
+ /** Largest chunk a writer is handed. */
6
+ export const ARCHIVE_CHUNK_BYTES = 64 * 1024;
7
+ const encoder = new TextEncoder();
8
+ /**
9
+ * NDJSON lines: a header, then history messages, then turn-prefix messages.
10
+ * The summary and usage are model output kept in the compaction entry, so
11
+ * they are not archived.
12
+ */
13
+ function* archiveLines(source) {
14
+ const line = (value) => encoder.encode(`${JSON.stringify(value)}\n`);
15
+ yield line({
16
+ format: ARCHIVE_FORMAT,
17
+ version: 1,
18
+ contextRevision: source.contextRevision,
19
+ firstKeptEntryId: source.firstKeptEntryId,
20
+ counts: {
21
+ history: source.messagesToSummarize.length,
22
+ turn_prefix: source.turnPrefixMessages.length,
23
+ },
24
+ });
25
+ for (const message of source.messagesToSummarize) {
26
+ yield line({ section: "history", message });
27
+ }
28
+ for (const message of source.turnPrefixMessages) {
29
+ yield line({ section: "turn_prefix", message });
30
+ }
31
+ }
32
+ /** Pass 1: hash and count the lines one at a time, retaining none. */
33
+ export function measureArchive(lines) {
34
+ const hash = sha256.create();
35
+ let byteLength = 0;
36
+ for (const bytes of lines) {
37
+ hash.update(bytes);
38
+ byteLength += bytes.byteLength;
39
+ }
40
+ const digest = bytesToHex(hash.digest());
41
+ return { byteLength, sha256: digest, materialKey: digest.slice(0, 32) };
42
+ }
43
+ function* sliceChunks(source) {
44
+ for (const bytes of archiveLines(source)) {
45
+ for (let at = 0; at < bytes.byteLength; at += ARCHIVE_CHUNK_BYTES) {
46
+ yield bytes.subarray(at, at + ARCHIVE_CHUNK_BYTES);
47
+ }
48
+ }
49
+ }
50
+ function archiveChunks(source) {
51
+ return {
52
+ [Symbol.asyncIterator]: () => {
53
+ const chunks = sliceChunks(source);
54
+ return { next: () => Promise.resolve(chunks.next()) };
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * Prepare one archive write: pass 1 measures, and `chunks()` is pass 2,
60
+ * re-encoding on demand so no full copy is ever held.
61
+ */
62
+ export function prepareArchiveWrite(sessionId, source) {
63
+ const measure = measureArchive(archiveLines(source));
64
+ return {
65
+ sessionId,
66
+ contextRevision: source.contextRevision,
67
+ ...measure,
68
+ chunks: () => archiveChunks(source),
69
+ };
70
+ }
@@ -0,0 +1,20 @@
1
+ /** Longest path or key segment a writer is handed for a Session. */
2
+ export declare const ARCHIVE_SEGMENT_MAX = 200;
3
+ /**
4
+ * The one place a Session id becomes a path or key segment, shared by every
5
+ * host writer. Shiitake lets a client name its Session with any text up to 256
6
+ * characters, but a segment must be one safe path element.
7
+ *
8
+ * - A plain id (letters, digits, `_`, `-`, not starting with `-`) is kept.
9
+ * - Any other id is `e.` followed by its UTF-8 bytes, each byte outside the
10
+ * plain alphabet written as `.xx` (lowercase hex). A plain id never contains
11
+ * `.`, so an encoded segment can never equal a plain one, and `.` always
12
+ * begins an escape, so two ids never share a segment and the id is
13
+ * recoverable.
14
+ * - A segment that would pass {@link ARCHIVE_SEGMENT_MAX} is `h.` and the
15
+ * SHA-256 of the id's UTF-8 bytes: bounded, collision-free in practice, and
16
+ * not reversible.
17
+ *
18
+ * The empty id has no segment; it returns `""`, which writers refuse.
19
+ */
20
+ export declare function archiveSessionSegment(sessionId: string): string;
@@ -0,0 +1,42 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { bytesToHex } from "@noble/hashes/utils.js";
3
+ /** Longest path or key segment a writer is handed for a Session. */
4
+ export const ARCHIVE_SEGMENT_MAX = 200;
5
+ const plain = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
6
+ const literal = /^[A-Za-z0-9_-]$/;
7
+ const encoder = new TextEncoder();
8
+ /**
9
+ * The one place a Session id becomes a path or key segment, shared by every
10
+ * host writer. Shiitake lets a client name its Session with any text up to 256
11
+ * characters, but a segment must be one safe path element.
12
+ *
13
+ * - A plain id (letters, digits, `_`, `-`, not starting with `-`) is kept.
14
+ * - Any other id is `e.` followed by its UTF-8 bytes, each byte outside the
15
+ * plain alphabet written as `.xx` (lowercase hex). A plain id never contains
16
+ * `.`, so an encoded segment can never equal a plain one, and `.` always
17
+ * begins an escape, so two ids never share a segment and the id is
18
+ * recoverable.
19
+ * - A segment that would pass {@link ARCHIVE_SEGMENT_MAX} is `h.` and the
20
+ * SHA-256 of the id's UTF-8 bytes: bounded, collision-free in practice, and
21
+ * not reversible.
22
+ *
23
+ * The empty id has no segment; it returns `""`, which writers refuse.
24
+ */
25
+ export function archiveSessionSegment(sessionId) {
26
+ if (sessionId === "")
27
+ return "";
28
+ if (plain.test(sessionId) && sessionId.length <= ARCHIVE_SEGMENT_MAX)
29
+ return sessionId;
30
+ const bytes = encoder.encode(sessionId);
31
+ let encoded = "e.";
32
+ for (const byte of bytes) {
33
+ const character = String.fromCharCode(byte);
34
+ encoded +=
35
+ byte < 0x80 && literal.test(character)
36
+ ? character
37
+ : `.${byte.toString(16).padStart(2, "0")}`;
38
+ if (encoded.length > ARCHIVE_SEGMENT_MAX)
39
+ return `h.${bytesToHex(sha256(bytes))}`;
40
+ }
41
+ return encoded;
42
+ }
@@ -0,0 +1,35 @@
1
+ import type { Effect } from "effect";
2
+ /**
3
+ * One archive handed to a host's writer. `chunks` is re-iterable; each chunk
4
+ * is at most 64 KiB and the chunks total exactly `byteLength` bytes whose
5
+ * SHA-256 is `sha256`.
6
+ */
7
+ export type ArchiveWriteV1 = Readonly<{
8
+ sessionId: string;
9
+ contextRevision: string;
10
+ /** First 32 hex characters of `sha256`: the content digest key. */
11
+ materialKey: string;
12
+ byteLength: number;
13
+ /** Lowercase hex SHA-256 of the archive bytes. */
14
+ sha256: string;
15
+ chunks: () => AsyncIterable<Uint8Array>;
16
+ }>;
17
+ /** What a writer proves it stored; encoded by the host as the opaque receipt. */
18
+ export type ArchiveReceiptV1 = Readonly<{
19
+ key: string;
20
+ sha256: string;
21
+ byteLength: number;
22
+ }>;
23
+ /**
24
+ * Writer failure. `conflict` is different bytes under an existing key,
25
+ * `invalid` a chunk over the bound or a stream that disagrees with the
26
+ * declared length or digest, `unavailable` a store that could not be written.
27
+ */
28
+ export type ArchiveWriteError = Readonly<{
29
+ _tag: "ArchiveWriteError";
30
+ reason: "unavailable" | "conflict" | "invalid";
31
+ }>;
32
+ /** Host-owned storage port; Zookeeper owns the bytes, the host owns the place. */
33
+ export type ArchiveWriter = Readonly<{
34
+ write: (input: ArchiveWriteV1) => Effect.Effect<ArchiveReceiptV1, ArchiveWriteError>;
35
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { type CompactionHandoffV0, type ExtensionGrantV0, type RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
2
+ import type { ArchiveWriter } from "./archive-writer.js";
3
+ /** The one grant Zookeeper's handoff needs; a host appends it beside the extension. */
4
+ export declare const zookeeperCompactionGrant: ExtensionGrantV0;
5
+ /**
6
+ * Archive exactly what leaves the model context. A write failure is a handoff
7
+ * failure, never a policy discard, so compaction stops with the context intact.
8
+ */
9
+ export declare function archiveHandoff(writer: ArchiveWriter): CompactionHandoffV0;
10
+ /**
11
+ * Zookeeper's compaction handoff over a host-bound writer. The host owns the
12
+ * Agent and the place; the view supplies the Session.
13
+ */
14
+ export declare function zookeeperCompaction(options: Readonly<{
15
+ writer: ArchiveWriter;
16
+ }>): RosteredExtensionV0;
@@ -0,0 +1,49 @@
1
+ import { defineExtension, ExtensionId, } from "@fungi.computer/shiitake/extensions";
2
+ import * as Effect from "effect/Effect";
3
+ import { prepareArchiveWrite } from "./archive-format.js";
4
+ const id = ExtensionId.make("zookeeper-compaction");
5
+ /** The one grant Zookeeper's handoff needs; a host appends it beside the extension. */
6
+ export const zookeeperCompactionGrant = {
7
+ id,
8
+ capabilities: ["compaction_handoff"],
9
+ };
10
+ /** The opaque receipt Shiitake carries: the writer's receipt as JSON. */
11
+ function encodeReceipt(receipt) {
12
+ return JSON.stringify(receipt);
13
+ }
14
+ /**
15
+ * Archive exactly what leaves the model context. A write failure is a handoff
16
+ * failure, never a policy discard, so compaction stops with the context intact.
17
+ */
18
+ export function archiveHandoff(writer) {
19
+ return { consume: (view) => archive(writer, view) };
20
+ }
21
+ function archive(writer, view) {
22
+ if (view.messagesToSummarize.length === 0 &&
23
+ view.turnPrefixMessages.length === 0)
24
+ return Effect.succeed({ type: "discarded_by_policy" });
25
+ const write = prepareArchiveWrite(view.sessionId, {
26
+ contextRevision: view.contextRevision,
27
+ firstKeptEntryId: view.result.firstKeptEntryId,
28
+ messagesToSummarize: view.messagesToSummarize,
29
+ turnPrefixMessages: view.turnPrefixMessages,
30
+ });
31
+ return writer.write(write).pipe(Effect.map((receipt) => ({
32
+ type: "accepted",
33
+ receipt: encodeReceipt(receipt),
34
+ })), Effect.mapError((error) => ({
35
+ _tag: "CompactionHandoffError",
36
+ message: `Compaction archive write failed: ${error.reason}`,
37
+ })));
38
+ }
39
+ /**
40
+ * Zookeeper's compaction handoff over a host-bound writer. The host owns the
41
+ * Agent and the place; the view supplies the Session.
42
+ */
43
+ export function zookeeperCompaction(options) {
44
+ return defineExtension({ id, version: "1.0.0" }).define({
45
+ compaction: () => ({
46
+ consume: archiveHandoff(options.writer).consume,
47
+ }),
48
+ });
49
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./archive-format.js";
2
+ export * from "./archive-key.js";
3
+ export * from "./archive-writer.js";
4
+ export * from "./handoff.js";
5
+ export * from "./patrol.js";
6
+ export * from "./pressure.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./archive-format.js";
2
+ export * from "./archive-key.js";
3
+ export * from "./archive-writer.js";
4
+ export * from "./handoff.js";
5
+ export * from "./patrol.js";
6
+ export * from "./pressure.js";
@@ -0,0 +1,58 @@
1
+ import type { ColdSessionPage, ColdSessionPageRequest } from "@fungi.computer/shiitake";
2
+ import { type StorageBudget } from "./pressure.js";
3
+ /** What one patrol pass measured. The patrol evicts nothing. */
4
+ export type PatrolReport = Readonly<{
5
+ status: "quiet";
6
+ usedBytes: number;
7
+ waterlineBytes: number;
8
+ }> | Readonly<{
9
+ status: "measured";
10
+ usedBytes: number;
11
+ waterlineBytes: number;
12
+ /** Sessions read in this page. */
13
+ sessions: number;
14
+ /** Bytes of the Sessions read. */
15
+ sessionBytes: number;
16
+ /** Bytes of the counted Sessions that are idle. */
17
+ idleBytes: number;
18
+ /** True when colder Sessions remain beyond this page. */
19
+ more: boolean;
20
+ }> | Readonly<{
21
+ status: "unavailable";
22
+ /** What could not be read: the platform's size or the Session page. */
23
+ source: "store-size" | "cold-sessions";
24
+ /** The failure's name only; no stored data. */
25
+ failure: string;
26
+ }>;
27
+ /**
28
+ * The patrol as Shiitake host work: structurally Cloudflare's
29
+ * `CloudflareHostWork`, declared here so Zookeeper takes no Cloudflare types.
30
+ */
31
+ export type PatrolWork = Readonly<{
32
+ /** Read synchronously inside the alarm transaction. */
33
+ pending: () => Readonly<{
34
+ due: boolean;
35
+ nextWakeAt: number | null;
36
+ }>;
37
+ /** One bounded pass, requested on every durable wake. */
38
+ run: () => Promise<void>;
39
+ }>;
40
+ /** What the patrol needs from its host. */
41
+ export type PatrolOptions = Readonly<{
42
+ /** The platform's synchronous size of the store, and the host's budget. */
43
+ budget: StorageBudget;
44
+ /** The owner's bounded, read-only page of Sessions, coldest first. */
45
+ readColdSessions: (request: ColdSessionPageRequest) => Promise<ColdSessionPage>;
46
+ /** Receives what each pass measured; the host logs it. */
47
+ report: (report: PatrolReport) => void;
48
+ now?: () => number;
49
+ cadenceMs?: number;
50
+ }>;
51
+ /**
52
+ * Open the patrol as Shiitake host work. `pending` is read synchronously inside
53
+ * the alarm transaction, so it uses only the platform's synchronous size of the
54
+ * store: it reports a wake only while the store is over the waterline, and
55
+ * `null` when it is quiet. `run` is one bounded pass that measures and
56
+ * reports. It evicts nothing.
57
+ */
58
+ export declare function openPatrol(options: PatrolOptions): PatrolWork;
package/dist/patrol.js ADDED
@@ -0,0 +1,107 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { checkedBudgetBytes } from "./pressure.js";
3
+ /**
4
+ * Provisional. The patrol has work once the store holds more than this
5
+ * fraction of the host's budget; it is below the refusal door's 80%, so the
6
+ * patrol sees a growing store before admission is refused.
7
+ */
8
+ const patrolWaterlineFraction = 0.5;
9
+ /** Provisional. How soon the patrol looks again while the store stays over. */
10
+ const defaultCadenceMs = 5 * 60_000;
11
+ /** A wake is never closer than this, whatever cadence the host asks for. */
12
+ const minimumCadenceMs = 1_000;
13
+ /** Sessions measured in one pass: one bounded page, never a walk of the store. */
14
+ const patrolPageLimit = 50;
15
+ const decodeBytes = Schema.decodeUnknownResult(Schema.Number.pipe(Schema.check(Schema.isFinite()), Schema.check(Schema.isGreaterThanOrEqualTo(0))));
16
+ /**
17
+ * Open the patrol as Shiitake host work. `pending` is read synchronously inside
18
+ * the alarm transaction, so it uses only the platform's synchronous size of the
19
+ * store: it reports a wake only while the store is over the waterline, and
20
+ * `null` when it is quiet. `run` is one bounded pass that measures and
21
+ * reports. It evicts nothing.
22
+ */
23
+ export function openPatrol(options) {
24
+ const now = options.now ?? Date.now;
25
+ const cadenceMs = Math.max(options.cadenceMs ?? defaultCadenceMs, minimumCadenceMs);
26
+ const waterlineBytes = checkedBudgetBytes(options.budget) * patrolWaterlineFraction;
27
+ const measureStore = () => {
28
+ try {
29
+ const used = decodeBytes(options.budget.usedBytes());
30
+ return used._tag === "Success"
31
+ ? { ok: true, usedBytes: used.success }
32
+ : { ok: false, failure: "InvalidStoreSize" };
33
+ }
34
+ catch (error) {
35
+ return {
36
+ ok: false,
37
+ failure: error instanceof Error ? error.name : "NonErrorThrown",
38
+ };
39
+ }
40
+ };
41
+ const readPage = async () => {
42
+ try {
43
+ return {
44
+ ok: true,
45
+ page: await options.readColdSessions({ limit: patrolPageLimit }),
46
+ };
47
+ }
48
+ catch (error) {
49
+ return {
50
+ ok: false,
51
+ failure: error instanceof Error ? error.name : "NonErrorThrown",
52
+ };
53
+ }
54
+ };
55
+ return Object.freeze({
56
+ pending: () => {
57
+ const store = measureStore();
58
+ // An unreadable size gives no wake, so the patrol idles until the next
59
+ // commit asks again; Zookeeper's pressure check refuses admission
60
+ // meanwhile. `run` reports the failure on any wake that does happen.
61
+ const over = store.ok && store.usedBytes > waterlineBytes;
62
+ return { due: false, nextWakeAt: over ? now() + cadenceMs : null };
63
+ },
64
+ run: async () => {
65
+ const store = measureStore();
66
+ if (!store.ok) {
67
+ options.report({
68
+ status: "unavailable",
69
+ source: "store-size",
70
+ failure: store.failure,
71
+ });
72
+ return;
73
+ }
74
+ const { usedBytes } = store;
75
+ if (usedBytes <= waterlineBytes) {
76
+ options.report({ status: "quiet", usedBytes, waterlineBytes });
77
+ return;
78
+ }
79
+ const read = await readPage();
80
+ if (!read.ok) {
81
+ options.report({
82
+ status: "unavailable",
83
+ source: "cold-sessions",
84
+ failure: read.failure,
85
+ });
86
+ return;
87
+ }
88
+ const { page } = read;
89
+ let sessionBytes = 0;
90
+ let idleBytes = 0;
91
+ for (const row of page.sessions) {
92
+ sessionBytes += row.bytes;
93
+ if (row.idle)
94
+ idleBytes += row.bytes;
95
+ }
96
+ options.report({
97
+ status: "measured",
98
+ usedBytes,
99
+ waterlineBytes,
100
+ sessions: page.sessions.length,
101
+ sessionBytes,
102
+ idleBytes,
103
+ more: page.next !== undefined,
104
+ });
105
+ },
106
+ });
107
+ }
@@ -0,0 +1,39 @@
1
+ import { type StoragePressureCheck } from "@fungi.computer/shiitake";
2
+ /** A Durable Object's disk budget, with the platform's own measure of it. */
3
+ export type StorageBudget = Readonly<{
4
+ /**
5
+ * Bytes the owner database occupies now, read from the platform, such as
6
+ * Cloudflare's `ctx.storage.sql.databaseSize`.
7
+ */
8
+ usedBytes: () => number;
9
+ /** The host's budget for those bytes. */
10
+ budgetBytes: number;
11
+ }>;
12
+ /**
13
+ * A Durable Object's disk quota: 10 GB per object on Cloudflare Workers Paid.
14
+ * Every Shiitake host measures against it, so the number has one owner.
15
+ */
16
+ export declare const durableObjectStorageBudgetBytes = 10000000000;
17
+ /** The storage a Cloudflare Durable Object reports its own SQLite size on. */
18
+ export type DurableObjectSizedStorage = Readonly<{
19
+ sql: Readonly<{
20
+ databaseSize: number;
21
+ }>;
22
+ }>;
23
+ /**
24
+ * The one budget a Durable Object's pressure check and patrol both measure:
25
+ * the platform's own `databaseSize` against the per-object quota.
26
+ */
27
+ export declare function durableObjectStorageBudget(storage: DurableObjectSizedStorage): StorageBudget;
28
+ /**
29
+ * The budget as a positive whole number of bytes. Pressure and the patrol both
30
+ * measure against it, so a bad budget fails when they open, never later as a
31
+ * NaN threshold.
32
+ */
33
+ export declare function checkedBudgetBytes(budget: StorageBudget): number;
34
+ /**
35
+ * Open the storage-pressure check for Shiitake's `storagePressure` option.
36
+ * It reports pressure at 80% of the budget and keeps reporting it until
37
+ * usage falls below 75%.
38
+ */
39
+ export declare function openStoragePressure(budget: StorageBudget): StoragePressureCheck;
@@ -0,0 +1,63 @@
1
+ import { ShiitakeError, } from "@fungi.computer/shiitake";
2
+ import * as Schema from "effect/Schema";
3
+ /**
4
+ * Provisional disk thresholds, as fractions of the host's budget. At
5
+ * `refuseAt` new Sessions and new prompts are refused; once refusing, they
6
+ * are admitted again only below `readmitBelow`, so admission does not flap
7
+ * while usage hovers at the threshold. The gap is the room the work already
8
+ * admitted keeps for finishing: its writes never pass through Shiitake's
9
+ * refusal door.
10
+ */
11
+ const storagePressureThresholds = Object.freeze({
12
+ refuseAt: 0.8,
13
+ readmitBelow: 0.75,
14
+ });
15
+ const decodeBudgetBytes = Schema.decodeUnknownResult(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))));
16
+ const decodeUsedBytes = Schema.decodeUnknownResult(Schema.Number.pipe(Schema.check(Schema.isFinite()), Schema.check(Schema.isGreaterThanOrEqualTo(0))));
17
+ /**
18
+ * A Durable Object's disk quota: 10 GB per object on Cloudflare Workers Paid.
19
+ * Every Shiitake host measures against it, so the number has one owner.
20
+ */
21
+ export const durableObjectStorageBudgetBytes = 10_000_000_000;
22
+ /**
23
+ * The one budget a Durable Object's pressure check and patrol both measure:
24
+ * the platform's own `databaseSize` against the per-object quota.
25
+ */
26
+ export function durableObjectStorageBudget(storage) {
27
+ return {
28
+ usedBytes: () => storage.sql.databaseSize,
29
+ budgetBytes: durableObjectStorageBudgetBytes,
30
+ };
31
+ }
32
+ /**
33
+ * The budget as a positive whole number of bytes. Pressure and the patrol both
34
+ * measure against it, so a bad budget fails when they open, never later as a
35
+ * NaN threshold.
36
+ */
37
+ export function checkedBudgetBytes(budget) {
38
+ const budgetBytes = decodeBudgetBytes(budget.budgetBytes);
39
+ if (budgetBytes._tag === "Failure")
40
+ throw new ShiitakeError("contract", "invalid", "Storage budget must be a positive whole number of bytes");
41
+ return budgetBytes.success;
42
+ }
43
+ /**
44
+ * Open the storage-pressure check for Shiitake's `storagePressure` option.
45
+ * It reports pressure at 80% of the budget and keeps reporting it until
46
+ * usage falls below 75%.
47
+ */
48
+ export function openStoragePressure(budget) {
49
+ const limit = checkedBudgetBytes(budget);
50
+ let refusing = false;
51
+ return Object.freeze({
52
+ underPressure: () => {
53
+ const used = decodeUsedBytes(budget.usedBytes());
54
+ if (used._tag === "Failure")
55
+ throw new ShiitakeError("store", "unavailable", "Owner storage size is unavailable");
56
+ const fraction = used.success / limit;
57
+ refusing = refusing
58
+ ? fraction >= storagePressureThresholds.readmitBelow
59
+ : fraction >= storagePressureThresholds.refuseAt;
60
+ return refusing;
61
+ },
62
+ });
63
+ }
@@ -0,0 +1,4 @@
1
+ import type { ArchiveSource } from "./archive-format.js";
2
+ /** One compaction view whose archive bytes are pinned by the golden test. */
3
+ export declare const goldenSource: ArchiveSource;
4
+ export declare const goldenArchiveText: string;
@@ -0,0 +1,17 @@
1
+ /** One compaction view whose archive bytes are pinned by the golden test. */
2
+ export const goldenSource = {
3
+ contextRevision: "row-42",
4
+ firstKeptEntryId: "entry-9",
5
+ messagesToSummarize: [
6
+ { role: "user", content: "héllo \u{1F344}", timestamp: 1 },
7
+ { role: "user", content: "second", timestamp: 2 },
8
+ ],
9
+ turnPrefixMessages: [{ role: "user", content: "prefix", timestamp: 3 }],
10
+ };
11
+ export const goldenArchiveText = [
12
+ '{"format":"zookeeper.compaction-archive/1","version":1,"contextRevision":"row-42","firstKeptEntryId":"entry-9","counts":{"history":2,"turn_prefix":1}}',
13
+ '{"section":"history","message":{"role":"user","content":"héllo \u{1F344}","timestamp":1}}',
14
+ '{"section":"history","message":{"role":"user","content":"second","timestamp":2}}',
15
+ '{"section":"turn_prefix","message":{"role":"user","content":"prefix","timestamp":3}}',
16
+ "",
17
+ ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@fungi.computer/zookeeper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Durable Object store governor: the storage budget and pressure policy behind Shiitake's refusal door",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./testing": {
15
+ "types": "./dist/testing.d.ts",
16
+ "import": "./dist/testing.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@fungi.computer/shiitake": "0.3.0",
26
+ "@noble/hashes": "2.4.0",
27
+ "effect": "4.0.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "24.13.3",
31
+ "typescript": "5.9.3",
32
+ "vitest": "4.1.11"
33
+ },
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/fungi-computer/zookeeper.git"
38
+ },
39
+ "botanical": {
40
+ "role": "package",
41
+ "operations": {
42
+ "lint": "required",
43
+ "typecheck": "required",
44
+ "build": "required",
45
+ "test": "required",
46
+ "docs": {
47
+ "status": "not-applicable",
48
+ "reason": "README owns this host-governor contract; no generated documentation site"
49
+ },
50
+ "pack": "required",
51
+ "smoke": "required",
52
+ "stranger": "required"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "clean": "node --input-type=module --eval \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"",
57
+ "build": "pnpm run clean && tsc -p tsconfig.build.json",
58
+ "typecheck": "tsc -p tsconfig.json --noEmit",
59
+ "lint": "eslint src test --max-warnings=0 && ../../node_modules/.bin/oxlint --config ../../oxlint.config.ts --deny-warnings src test",
60
+ "test": "vitest run --config vitest.config.mjs",
61
+ "pack": "mkdir -p ../../.botanical/zookeeper-pack && pnpm pack --pack-destination \"$PWD/../../.botanical/zookeeper-pack\"",
62
+ "smoke": "node test/stranger/runtime.mjs",
63
+ "stranger": "tsx ../../tooling/repository/src/artifacts/cli.ts --repo-root ../.. .",
64
+ "format:check": "node ../../tooling/repository/scripts/format-package-check.mjs",
65
+ "lint:anti-slop": "node ../../tooling/repository/scripts/lint-package-anti-slop.mjs"
66
+ }
6
67
  }