@fungi.computer/shiitake 0.2.0 → 0.3.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.
Files changed (114) hide show
  1. package/README.md +155 -34
  2. package/dist/agent-control.d.ts +77 -5
  3. package/dist/agent-control.js +271 -65
  4. package/dist/client-entry.d.ts +15 -8
  5. package/dist/client-entry.js +7 -5
  6. package/dist/client-wire.d.ts +24 -8
  7. package/dist/client-wire.js +53 -11
  8. package/dist/client.d.ts +56 -11
  9. package/dist/client.js +1 -1
  10. package/dist/cloudflare.d.ts +42 -23
  11. package/dist/cloudflare.js +76 -54
  12. package/dist/cold-sessions.d.ts +28 -0
  13. package/dist/cold-sessions.js +1 -0
  14. package/dist/delegation.d.ts +214 -0
  15. package/dist/delegation.js +319 -0
  16. package/dist/effect.d.ts +1 -1
  17. package/dist/errors.d.ts +2 -2
  18. package/dist/errors.js +2 -2
  19. package/dist/extension-contract.d.ts +10 -2
  20. package/dist/extensions.d.ts +6 -2
  21. package/dist/fleet.d.ts +43 -9
  22. package/dist/fleet.js +88 -25
  23. package/dist/hosted.js +1 -0
  24. package/dist/http-routes.d.ts +113 -29
  25. package/dist/http-routes.js +18 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/internal/accepted-run-executor.d.ts +5 -0
  28. package/dist/internal/accepted-run-executor.js +10 -4
  29. package/dist/internal/admission-door.d.ts +3 -0
  30. package/dist/internal/admission-door.js +12 -2
  31. package/dist/internal/client-boundary.js +7 -1
  32. package/dist/internal/compaction/compaction-slot.d.ts +36 -0
  33. package/dist/internal/compaction/compaction-slot.js +41 -0
  34. package/dist/internal/compaction/compaction.d.ts +11 -8
  35. package/dist/internal/compaction/compaction.js +62 -34
  36. package/dist/internal/compaction/session-compaction.d.ts +8 -16
  37. package/dist/internal/compaction/session-compaction.js +358 -119
  38. package/dist/internal/compaction/settings.d.ts +1 -1
  39. package/dist/internal/compaction/settings.js +5 -0
  40. package/dist/internal/compaction/utils.d.ts +19 -16
  41. package/dist/internal/compaction/utils.js +30 -84
  42. package/dist/internal/contracts.d.ts +40 -13
  43. package/dist/internal/coordinator.js +28 -10
  44. package/dist/internal/effect-client.d.ts +4 -2
  45. package/dist/internal/effect-client.js +29 -0
  46. package/dist/internal/effect-contracts.d.ts +17 -4
  47. package/dist/internal/extension-runtime.d.ts +5 -0
  48. package/dist/internal/extension-runtime.js +184 -89
  49. package/dist/internal/host-meta.d.ts +7 -0
  50. package/dist/internal/model-acquisition.js +7 -1
  51. package/dist/internal/model-preference.d.ts +4 -2
  52. package/dist/internal/model-preference.js +2 -1
  53. package/dist/internal/promise-facade.js +4 -16
  54. package/dist/internal/run-failure-reason.d.ts +10 -0
  55. package/dist/internal/run-failure-reason.js +19 -0
  56. package/dist/internal/run-scope.d.ts +4 -1
  57. package/dist/internal/run-scope.js +38 -5
  58. package/dist/internal/runtime-coordinator.d.ts +2 -1
  59. package/dist/internal/runtime-coordinator.js +65 -4
  60. package/dist/internal/runtime-core.js +1 -0
  61. package/dist/internal/runtime-reconciliation.js +42 -6
  62. package/dist/internal/session-context.d.ts +1 -2
  63. package/dist/internal/session-context.js +8 -30
  64. package/dist/internal/session-status.d.ts +2 -1
  65. package/dist/internal/session-status.js +4 -1
  66. package/dist/internal/woodstock/client-projection.d.ts +6 -5
  67. package/dist/internal/woodstock/client-projection.js +79 -53
  68. package/dist/internal/woodstock/cold-sessions.d.ts +4 -0
  69. package/dist/internal/woodstock/cold-sessions.js +90 -0
  70. package/dist/internal/woodstock/extension-store.d.ts +2 -0
  71. package/dist/internal/woodstock/extension-store.js +4 -0
  72. package/dist/internal/woodstock/fleet-store.d.ts +5 -1
  73. package/dist/internal/woodstock/fleet-store.js +17 -5
  74. package/dist/internal/woodstock/index.d.ts +11 -3
  75. package/dist/internal/woodstock/index.js +33 -14
  76. package/dist/internal/woodstock/progress.d.ts +14 -1
  77. package/dist/internal/woodstock/progress.js +97 -19
  78. package/dist/internal/woodstock/run-recovery.js +6 -4
  79. package/dist/internal/woodstock/session-attention.d.ts +1 -1
  80. package/dist/internal/woodstock/session-attention.js +2 -2
  81. package/dist/internal/woodstock/session-entry.d.ts +110 -43
  82. package/dist/internal/woodstock/session-entry.js +63 -20
  83. package/dist/internal/woodstock/session-inbox.js +2 -0
  84. package/dist/internal/woodstock/session-store.d.ts +104 -24
  85. package/dist/internal/woodstock/session-store.js +937 -246
  86. package/dist/internal/woodstock/storage-admission.d.ts +8 -8
  87. package/dist/internal/woodstock/storage-admission.js +8 -34
  88. package/dist/internal/woodstock/storage-conformance.d.ts +15 -0
  89. package/dist/internal/woodstock/storage-conformance.js +489 -0
  90. package/dist/model-preference.d.ts +2 -2
  91. package/dist/model-preference.js +2 -2
  92. package/dist/node.js +1 -1
  93. package/dist/remote-client.js +8 -1
  94. package/dist/session-attention.d.ts +8 -1
  95. package/dist/session-attention.js +6 -1
  96. package/dist/testing.d.ts +1 -1
  97. package/dist/testing.js +1 -0
  98. package/docs/api/README.md +18 -16
  99. package/docs/api/agent-control.md +171 -1
  100. package/docs/api/client-entry.md +26 -10
  101. package/docs/api/client.md +153 -37
  102. package/docs/api/cloudflare.md +76 -23
  103. package/docs/api/delegation.md +485 -0
  104. package/docs/api/effect.md +20 -2
  105. package/docs/api/extensions.md +21 -9
  106. package/docs/api/fleet.md +159 -104
  107. package/docs/api/http.md +4 -1
  108. package/docs/api/index.md +116 -20
  109. package/docs/api/internal/woodstock/storage-conformance.md +81 -0
  110. package/docs/api/model-preference.md +27 -10
  111. package/docs/api/testing.md +16 -4
  112. package/package.json +26 -14
  113. package/dist/internal/browser-sessions.d.ts +0 -31
  114. package/dist/internal/browser-sessions.js +0 -173
@@ -1,14 +1,14 @@
1
- import type { StorageBudget } from "../contracts.js";
1
+ import type { StoragePressureCheck } from "../contracts.js";
2
2
  /** The admission check for writes that start new work. */
3
3
  export type StorageAdmission = Readonly<{
4
4
  /**
5
- * Throw a typed `storage_pressure` refusal when the owner is near its
6
- * budget. Called only for a new Session or a new prompt, never for a
7
- * replayed request or for writes that finish admitted work.
5
+ * Throw a typed `storage_pressure` refusal when the host reports pressure.
6
+ * Called only for a new Session or a new prompt, never for a replayed
7
+ * request or for writes that finish admitted work.
8
8
  */
9
9
  admitNew: () => void;
10
10
  }>;
11
- /** The admission of a host that set no budget: nothing is refused. */
12
- export declare const admitWithoutBudget: StorageAdmission;
13
- /** Open the owner's admission check over the host's measured budget. */
14
- export declare function openStorageAdmission(budget: StorageBudget | undefined): StorageAdmission;
11
+ /** The admission of a host that set no check: nothing is refused. */
12
+ export declare const admitWithoutCheck: StorageAdmission;
13
+ /** Open the owner's refusal door over the host's storage-pressure check. */
14
+ export declare function openStorageAdmission(check: StoragePressureCheck | undefined): StorageAdmission;
@@ -1,43 +1,17 @@
1
- import * as Schema from "effect/Schema";
2
1
  import { ShiitakeError } from "../../errors.js";
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 this check.
9
- */
10
- const storagePressureThresholds = Object.freeze({
11
- refuseAt: 0.8,
12
- readmitBelow: 0.75,
13
- });
14
- const decodeBudgetBytes = Schema.decodeUnknownResult(Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))));
15
- const decodeUsedBytes = Schema.decodeUnknownResult(Schema.Number.pipe(Schema.check(Schema.isFinite()), Schema.check(Schema.isGreaterThanOrEqualTo(0))));
16
- /** The admission of a host that set no budget: nothing is refused. */
17
- export const admitWithoutBudget = Object.freeze({
2
+ /** The admission of a host that set no check: nothing is refused. */
3
+ export const admitWithoutCheck = Object.freeze({
18
4
  admitNew: () => {
19
- // No budget, so there is no storage pressure to refuse for.
5
+ // No check, so there is no storage pressure to refuse for.
20
6
  },
21
7
  });
22
- /** Open the owner's admission check over the host's measured budget. */
23
- export function openStorageAdmission(budget) {
24
- if (budget === undefined)
25
- return admitWithoutBudget;
26
- const budgetBytes = decodeBudgetBytes(budget.budgetBytes);
27
- if (budgetBytes._tag === "Failure")
28
- throw new ShiitakeError("contract", "invalid", "Storage budget must be a positive whole number of bytes");
29
- const limit = budgetBytes.success;
30
- let refusing = false;
8
+ /** Open the owner's refusal door over the host's storage-pressure check. */
9
+ export function openStorageAdmission(check) {
10
+ if (check === undefined)
11
+ return admitWithoutCheck;
31
12
  return Object.freeze({
32
13
  admitNew: () => {
33
- const used = decodeUsedBytes(budget.usedBytes());
34
- if (used._tag === "Failure")
35
- throw new ShiitakeError("store", "unavailable", "Owner storage size is unavailable");
36
- const fraction = used.success / limit;
37
- refusing = refusing
38
- ? fraction >= storagePressureThresholds.readmitBelow
39
- : fraction >= storagePressureThresholds.refuseAt;
40
- if (refusing)
14
+ if (check.underPressure())
41
15
  throw new ShiitakeError("store", "storage_pressure", "Storage is near its budget. New Sessions and prompts are refused until space is freed; running work continues.");
42
16
  },
43
17
  });
@@ -0,0 +1,15 @@
1
+ import type { SqliteOwner } from "../contracts.js";
2
+ /**
3
+ * Calls `use` exactly once with an empty, writable owner of the host under
4
+ * test, then releases the owner. It resolves after `use` returns.
5
+ */
6
+ export type WoodstockStorageProvider = (use: (owner: SqliteOwner) => void) => Promise<void>;
7
+ /** One named case. */
8
+ export type WoodstockConformanceCase = Readonly<{
9
+ /** Stable case name, for the runner's test title. */
10
+ name: string;
11
+ /** Open a fresh owner through the provider and run the case on it. */
12
+ run: () => Promise<void>;
13
+ }>;
14
+ /** Create the runner-independent cases for one Woodstock storage host. */
15
+ export declare function createWoodstockStorageConformance(withOwner: WoodstockStorageProvider): readonly WoodstockConformanceCase[];
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Runner-independent conformance cases for one Woodstock Session storage host.
3
+ *
4
+ * Adapted from Pi Durable's `src/testing/storage-conformance.ts`
5
+ * (`earendil-works/pi` at 8efd314f): every host that backs Woodstock with a
6
+ * {@link SqliteOwner} — the local `node:sqlite` owner and the Durable Object's
7
+ * `ctx.storage` — runs the same cases through Woodstock's own writers and
8
+ * readers. Fungi delta: Pi's suite checks a generic record store (ID minting,
9
+ * task and document tables); Woodstock's store is the parent-pointer Session
10
+ * tree, so these cases check atomic admission, request-ID replay, bounded
11
+ * lineage reads, branch, fork, placeholder completion, deletion and the inbox.
12
+ * The cases import no test runner, only `node:assert`, so they run inside a
13
+ * Worker with `nodejs_compat` too.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Schema from "effect/Schema";
20
+ import { openAdmissionDoor } from "../admission-door.js";
21
+ import { sessionIds } from "./identities.js";
22
+ import { appendCompactionNotice, branchSession, createSession, deleteSession, forkSession, prepareWoodstockSchema, readSessionInbox, } from "./index.js";
23
+ import { readSessionInboxChanges, readSessionInboxSummary, } from "./session-inbox.js";
24
+ import { appendCompactionTransitionOwned, appendSessionEntry, buildSessionContext, compactionHeadState, completeSessionLeaf, hasStoredSession, lineageRowLimit, readPendingSessionCommands, readSelectedPath, readSessionCommandAdmissionByRequest, readSessionTree, readStoredSessionSummary, setSessionTitle, } from "./session-store.js";
25
+ import { admitWithoutCheck } from "./storage-admission.js";
26
+ const contract = { core: "a".repeat(64), extensions: [] };
27
+ function commandEntry(text) {
28
+ return {
29
+ kind: "command",
30
+ workId: sessionIds.work.generate(),
31
+ requestId: `request-${sessionIds.fact.generate()}`,
32
+ receiptId: sessionIds.receipt.generate(),
33
+ message: { role: "user", content: text, timestamp: 1 },
34
+ contract,
35
+ };
36
+ }
37
+ function append(owner, sessionId, text) {
38
+ return appendSessionEntry(owner, {
39
+ sessionId,
40
+ id: sessionIds.fact.generate(),
41
+ entry: commandEntry(text),
42
+ });
43
+ }
44
+ function door(owner, enqueue = () => undefined) {
45
+ return openAdmissionDoor({
46
+ owner,
47
+ admitNew: admitWithoutCheck.admitNew,
48
+ contract: () => contract,
49
+ enqueue,
50
+ readQueueHead: () => ({ state: "idle" }),
51
+ commit: (mutation) => mutation,
52
+ requestWake: Effect.void,
53
+ });
54
+ }
55
+ function prompt(sessionId, requestId, text) {
56
+ return {
57
+ sessionId,
58
+ command: { type: "prompt", requestId, text, delivery: "followUp" },
59
+ when: "now",
60
+ origin: { kind: "user" },
61
+ };
62
+ }
63
+ function admit(owner, request) {
64
+ return Effect.runSync(door(owner).admit(request));
65
+ }
66
+ const decodeCount = Schema.decodeUnknownSync(Schema.Struct({ count: Schema.Number }));
67
+ function count(owner, table) {
68
+ return decodeCount(owner.sql.exec(`SELECT COUNT(*) AS count FROM ${table}`).one()).count;
69
+ }
70
+ const idle = () => undefined;
71
+ const usage = {
72
+ input: 7,
73
+ output: 3,
74
+ cacheRead: 0,
75
+ cacheWrite: 0,
76
+ totalTokens: 10,
77
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.5 },
78
+ };
79
+ /** Compact `sessionId` so its context starts at `head`, from the current leaf. */
80
+ function compact(owner, sessionId, head) {
81
+ const leaf = readSelectedPath(owner, { sessionId }).at(-1);
82
+ assert.ok(leaf !== undefined, "The Session has a leaf");
83
+ return appendCompactionTransitionOwned(owner, {
84
+ sessionId,
85
+ expectedLeafId: leaf.id,
86
+ workId: sessionIds.work.generate(),
87
+ trigger: "threshold",
88
+ tokensAfter: 10,
89
+ head,
90
+ summary: "summary",
91
+ tokensBefore: 100,
92
+ usage,
93
+ contextRevision: leaf.id,
94
+ });
95
+ }
96
+ /** Woodstock's typed refusal for an operation, matched by its codes. */
97
+ function refuses(operation, refusal) {
98
+ assert.throws(operation, { _tag: "ShiitakeError", ...refusal });
99
+ }
100
+ const sessionTables = [
101
+ "sessions",
102
+ "session_entries",
103
+ "session_command_admissions",
104
+ "session_inbox",
105
+ ];
106
+ /** Create the runner-independent cases for one Woodstock storage host. */
107
+ export function createWoodstockStorageConformance(withOwner) {
108
+ const prepared = (test) => (owner) => {
109
+ Effect.runSync(prepareWoodstockSchema(owner));
110
+ test(owner);
111
+ };
112
+ const testCase = (name, test) => ({
113
+ name,
114
+ run: () => withOwner(prepared(test)),
115
+ });
116
+ return Object.freeze([
117
+ testCase("prepares the schema idempotently over stored Sessions", (owner) => {
118
+ const root = append(owner, "prepared-twice", "kept");
119
+ Effect.runSync(prepareWoodstockSchema(owner));
120
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "prepared-twice" }).map(({ id }) => id), [root.id]);
121
+ }),
122
+ testCase("admits a command batch atomically and rolls every table back on failure", (owner) => {
123
+ const before = sessionTables.map((table) => count(owner, table));
124
+ let enqueued = 0;
125
+ const failing = door(owner, () => {
126
+ enqueued += 1;
127
+ if (enqueued === 2)
128
+ throw new Error("enqueue refused");
129
+ });
130
+ const exit = Effect.runSyncExit(failing.transact((within) => {
131
+ within(prompt("atomic-first", "atomic-1", "first"));
132
+ within(prompt("atomic-second", "atomic-2", "second"));
133
+ }));
134
+ assert.strictEqual(exit._tag, "Failure");
135
+ assert.strictEqual(enqueued, 2);
136
+ assert.deepStrictEqual(sessionTables.map((table) => count(owner, table)), before);
137
+ assert.strictEqual(hasStoredSession(owner, "atomic-first"), false);
138
+ assert.strictEqual(readSessionCommandAdmissionByRequest(owner, "atomic-first", "atomic-1"), undefined);
139
+ // The same batch commits whole once nothing refuses it.
140
+ Effect.runSync(door(owner).transact((within) => {
141
+ within(prompt("atomic-first", "atomic-1", "first"));
142
+ within(prompt("atomic-second", "atomic-2", "second"));
143
+ }));
144
+ assert.strictEqual(hasStoredSession(owner, "atomic-first"), true);
145
+ assert.strictEqual(hasStoredSession(owner, "atomic-second"), true);
146
+ }),
147
+ testCase("replays a request ID per Session and refuses a conflicting reuse", (owner) => {
148
+ const first = admit(owner, prompt("replay-a", "same-request", "hi"));
149
+ const replay = admit(owner, prompt("replay-a", "same-request", "hi"));
150
+ const elsewhere = admit(owner, prompt("replay-b", "same-request", "hi"));
151
+ assert.deepStrictEqual(replay, { ...first, delivery: "replay" });
152
+ assert.strictEqual(first.status, "accepted");
153
+ assert.strictEqual(elsewhere.status, "accepted");
154
+ assert.notStrictEqual(elsewhere.commandId, first.commandId, "A request ID is scoped to its Session");
155
+ refuses(() => admit(owner, prompt("replay-a", "same-request", "changed")), { boundary: "contract", cause: "conflict" });
156
+ assert.strictEqual(readPendingSessionCommands(owner, "replay-a", 10).total, 1);
157
+ }),
158
+ testCase("creates one Session per creation request ID", (owner) => {
159
+ const create = (requestId) => Effect.runSync(createSession(owner, requestId, admitWithoutCheck.admitNew));
160
+ const created = create("create-1");
161
+ assert.strictEqual(create("create-1"), created);
162
+ assert.ok(create("create-2") !== created, "A new request is a new id");
163
+ assert.strictEqual(count(owner, "sessions"), 2);
164
+ }),
165
+ testCase("keeps string identities and text lossless", (owner) => {
166
+ const text = 'Ünïcödé 🍄 \u0000 \u2028 \ud800 "quoted" \\ end';
167
+ const requestId = "request-🍄-ünï";
168
+ const accepted = admit(owner, prompt("lossless", requestId, text));
169
+ assert.strictEqual(accepted.status, "accepted");
170
+ const stored = readSessionCommandAdmissionByRequest(owner, "lossless", requestId);
171
+ assert.ok(stored, "A Unicode request ID indexes its admission");
172
+ assert.strictEqual(stored.id, accepted.commandId);
173
+ assert.ok("message" in stored.entry, "The admission stores its prompt");
174
+ assert.strictEqual(stored.entry.message.content, text);
175
+ const row = append(owner, "lossless", text);
176
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "lossless" }).at(-1)?.entry, row.entry);
177
+ // Text columns cannot hold a lone surrogate, so the door refuses such
178
+ // an identity before any write instead of indexing a lossy copy.
179
+ const before = sessionTables.map((table) => count(owner, table));
180
+ refuses(() => admit(owner, prompt("lossless", "request-\ud800", "lone")), { boundary: "contract", cause: "invalid" });
181
+ refuses(() => admit(owner, prompt("lossless-\ud800", "request-lone", "lone")), { boundary: "contract", cause: "invalid" });
182
+ assert.deepStrictEqual(sessionTables.map((table) => count(owner, table)), before);
183
+ }),
184
+ testCase("reads the complete selected lineage past every bounded lineage page", (owner) => {
185
+ const sessionId = "deep-lineage";
186
+ // One row past the bounded lineage page is the fewest that crosses it;
187
+ // one transaction keeps a file-backed host from syncing every row.
188
+ const total = lineageRowLimit + 1;
189
+ const ids = owner.transactionSync(() => Array.from({ length: total }, (_, index) => append(owner, sessionId, `turn ${String(index)}`).id));
190
+ const path = readSelectedPath(owner, { sessionId });
191
+ assert.deepStrictEqual(path.map(({ id }) => id), ids);
192
+ assert.deepStrictEqual(path.map(({ parentId }) => parentId), [null, ...ids.slice(0, -1)]);
193
+ assert.deepStrictEqual(path.map(({ sequence }) => sequence), ids.map((_, index) => index + 1));
194
+ }),
195
+ testCase("moves the selected branch in place and keeps the abandoned branch", (owner) => {
196
+ const sessionId = "branching";
197
+ const root = append(owner, sessionId, "root");
198
+ const abandoned = append(owner, sessionId, "abandoned");
199
+ Effect.runSync(branchSession(owner, {
200
+ sessionId,
201
+ type: "branch",
202
+ entryId: root.id,
203
+ assertIdleSession: idle,
204
+ }));
205
+ const selected = append(owner, sessionId, "selected");
206
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId }).map(({ id }) => id), [root.id, selected.id]);
207
+ const tree = readSessionTree(owner, { sessionId });
208
+ assert.deepStrictEqual(tree.map(({ row }) => row.id), [root.id]);
209
+ assert.deepStrictEqual(tree[0]?.children.map(({ row }) => row.id), [abandoned.id, selected.id]);
210
+ }),
211
+ testCase("forks a lineage with fresh identities and leaves its source unchanged", (owner) => {
212
+ const sessionId = "fork-source";
213
+ const root = append(owner, sessionId, "root");
214
+ const middle = append(owner, sessionId, "middle");
215
+ const tail = append(owner, sessionId, "tail");
216
+ const source = readSelectedPath(owner, { sessionId });
217
+ const forked = Effect.runSync(forkSession(owner, {
218
+ sessionId,
219
+ type: "fork",
220
+ entryId: middle.id,
221
+ assertIdleSession: idle,
222
+ })).sessionId;
223
+ const fork = readSelectedPath(owner, { sessionId: forked });
224
+ assert.strictEqual(fork.length, 2);
225
+ assert.ok(fork.every(({ id }) => id !== root.id && id !== middle.id), "Forked rows get fresh identities");
226
+ assert.deepStrictEqual(fork.map(({ parentId }) => parentId), [null, fork[0]?.id]);
227
+ assert.deepStrictEqual(buildSessionContext(fork), buildSessionContext([root, middle]));
228
+ append(owner, forked, "fork only");
229
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId }), source);
230
+ assert.strictEqual(source.at(-1)?.id, tail.id);
231
+ }),
232
+ testCase("completes a streaming placeholder in place with the exact message", (owner) => {
233
+ const sessionId = "placeholder";
234
+ const command = commandEntry("question");
235
+ appendSessionEntry(owner, {
236
+ sessionId,
237
+ id: sessionIds.fact.generate(),
238
+ entry: command,
239
+ });
240
+ const run = { kind: "run", value: "run-1" };
241
+ const turn = { kind: "turn", value: "turn-1", parent: run };
242
+ const message = {
243
+ kind: "message",
244
+ value: sessionIds.fact.generate(),
245
+ parent: turn,
246
+ };
247
+ const placeholder = appendSessionEntry(owner, {
248
+ sessionId,
249
+ id: message.value,
250
+ state: "streaming",
251
+ entry: {
252
+ kind: "message_start",
253
+ workId: command.workId,
254
+ run,
255
+ turn,
256
+ message,
257
+ role: "assistant",
258
+ },
259
+ });
260
+ const value = {
261
+ role: "assistant",
262
+ content: [{ type: "text", text: "answer" }],
263
+ api: "anthropic-messages",
264
+ provider: "anthropic",
265
+ model: "claude-test",
266
+ usage: {
267
+ input: 1,
268
+ output: 1,
269
+ cacheRead: 0,
270
+ cacheWrite: 0,
271
+ totalTokens: 2,
272
+ cost: {
273
+ input: 0,
274
+ output: 0,
275
+ cacheRead: 0,
276
+ cacheWrite: 0,
277
+ total: 0,
278
+ },
279
+ },
280
+ stopReason: "stop",
281
+ timestamp: 2,
282
+ };
283
+ const completed = completeSessionLeaf(owner, {
284
+ sessionId,
285
+ id: message.value,
286
+ entry: {
287
+ kind: "message",
288
+ workId: command.workId,
289
+ run,
290
+ turn,
291
+ message,
292
+ value,
293
+ },
294
+ });
295
+ assert.deepStrictEqual([completed.id, completed.parentId, completed.sequence], [placeholder.id, placeholder.parentId, placeholder.sequence]);
296
+ assert.strictEqual(completed.state, "complete");
297
+ const leaf = readSelectedPath(owner, { sessionId }).at(-1);
298
+ assert.ok(leaf?.entry.kind === "message", "The leaf is the message");
299
+ assert.strictEqual(leaf.id, placeholder.id);
300
+ assert.deepStrictEqual(leaf.entry.value, value);
301
+ }),
302
+ testCase("deletes one Session completely and leaves the others intact", (owner) => {
303
+ admit(owner, prompt("doomed", "doomed-1", "bye"));
304
+ const kept = append(owner, "kept", "stay");
305
+ const deleted = Effect.runSync(deleteSession(owner, {
306
+ sessionId: "doomed",
307
+ assertIdleSession: idle,
308
+ }));
309
+ assert.strictEqual(deleted, true);
310
+ assert.strictEqual(hasStoredSession(owner, "doomed"), false);
311
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "doomed" }), []);
312
+ assert.strictEqual(readSessionCommandAdmissionByRequest(owner, "doomed", "doomed-1"), undefined);
313
+ assert.strictEqual(readSessionInboxSummary(owner, "doomed"), undefined);
314
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "kept" }).map(({ id }) => id), [kept.id]);
315
+ assert.strictEqual(Effect.runSync(deleteSession(owner, {
316
+ sessionId: "doomed",
317
+ assertIdleSession: idle,
318
+ })), false);
319
+ }),
320
+ testCase("keeps a live lineage whole where it is written, and still deletes it whole", (owner) => {
321
+ const root = append(owner, "guarded", "root");
322
+ const leaf = append(owner, "guarded", "leaf");
323
+ assert.throws(() => owner.sql.exec("DELETE FROM session_entries WHERE id = ?", root.id), /Session entry still has children/u);
324
+ assert.throws(() => owner.sql.exec("UPDATE session_entries SET parent_id = NULL WHERE id = ?", leaf.id), /Session entry lineage columns are immutable/u);
325
+ assert.strictEqual(Effect.runSync(deleteSession(owner, {
326
+ sessionId: "guarded",
327
+ assertIdleSession: idle,
328
+ })), true);
329
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "guarded" }), []);
330
+ }),
331
+ testCase("refuses a REPLACE that would take a parent's place under another id", (owner) => {
332
+ const root = append(owner, "replaced", "root");
333
+ const middle = append(owner, "replaced", "middle");
334
+ const leaf = append(owner, "replaced", "leaf");
335
+ // Same Session and sequence as the middle row, a different id: the
336
+ // UNIQUE (session_id, sequence) conflict would delete the middle row.
337
+ try {
338
+ assert.throws(() => owner.sql.exec(`INSERT OR REPLACE INTO session_entries
339
+ (id, session_id, parent_id, sequence, type, role, state,
340
+ request_id, work_id, data_json, timestamp, completed_at)
341
+ SELECT 'replacement', session_id, parent_id, sequence, type,
342
+ role, state, 'replacement-request', work_id, data_json,
343
+ timestamp, completed_at
344
+ FROM session_entries WHERE id = ?`, middle.id), /Session entry would strand its children/u);
345
+ }
346
+ finally {
347
+ // A missing exception alone understates this regression: the old
348
+ // REPLACE actually deletes middle and orphans leaf.
349
+ assert.deepStrictEqual(owner.sql
350
+ .exec(`SELECT
351
+ EXISTS (SELECT 1 FROM session_entries WHERE id = ?) AS parent_exists,
352
+ (SELECT COUNT(*) FROM session_entries AS child
353
+ LEFT JOIN session_entries AS parent ON parent.id = child.parent_id
354
+ WHERE child.id = ? AND parent.id IS NULL) AS orphaned_children`, middle.id, leaf.id)
355
+ .one(), { parent_exists: 1, orphaned_children: 0 });
356
+ }
357
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "replaced" }).map(({ id }) => id), [root.id, middle.id, leaf.id]);
358
+ }),
359
+ testCase("refuses only the Session a writer outside Woodstock broke, from the next open", (owner) => {
360
+ append(owner, "healthy", "root");
361
+ append(owner, "damaged", "root");
362
+ const middle = append(owner, "damaged", "middle");
363
+ append(owner, "damaged", "leaf");
364
+ for (const guard of [
365
+ "session_entries_lineage_insert",
366
+ "session_entries_lineage_update",
367
+ "session_entries_lineage_delete",
368
+ "session_entries_damaged_session",
369
+ "session_entries_lineage_replace",
370
+ "session_entries_lineage_replace_position",
371
+ ])
372
+ owner.sql.exec(`DROP TRIGGER ${guard}`);
373
+ owner.sql.exec("DELETE FROM session_entries WHERE id = ?", middle.id);
374
+ Effect.runSync(prepareWoodstockSchema(owner));
375
+ assert.throws(() => readSelectedPath(owner, { sessionId: "damaged" }), /holds a broken lineage/u);
376
+ assert.throws(() => append(owner, "damaged", "more"), /holds a broken lineage/u);
377
+ const more = append(owner, "healthy", "more");
378
+ assert.deepStrictEqual(readSelectedPath(owner, { sessionId: "healthy" }).at(-1)?.id, more.id);
379
+ }),
380
+ testCase("judges a compaction head against the context start and the selected lineage", (owner) => {
381
+ const sessionId = "head-check";
382
+ const first = append(owner, sessionId, "first");
383
+ const second = append(owner, sessionId, "second");
384
+ const third = append(owner, sessionId, "third");
385
+ const state = (head) => compactionHeadState(owner, { sessionId, head });
386
+ // Before any compaction every entry on the lineage is current.
387
+ assert.strictEqual(state(first.id), "current");
388
+ compact(owner, sessionId, second.id);
389
+ // Older than the context start: a later compaction already cut it.
390
+ assert.strictEqual(state(first.id), "stale");
391
+ // At or after the context start: still inside the model context.
392
+ assert.strictEqual(state(second.id), "current");
393
+ assert.strictEqual(state(third.id), "current");
394
+ assert.strictEqual(state("unknown-entry"), "stale");
395
+ assert.strictEqual(compactionHeadState(owner, { sessionId: "no-such", head: first.id }), "stale");
396
+ }),
397
+ testCase("treats a compaction head off the selected lineage as stale", (owner) => {
398
+ const sessionId = "head-off-lineage";
399
+ const root = append(owner, sessionId, "root");
400
+ const abandoned = append(owner, sessionId, "abandoned");
401
+ Effect.runSync(branchSession(owner, {
402
+ sessionId,
403
+ type: "branch",
404
+ entryId: root.id,
405
+ assertIdleSession: idle,
406
+ }));
407
+ const selected = append(owner, sessionId, "selected");
408
+ assert.strictEqual(compactionHeadState(owner, { sessionId, head: abandoned.id }), "stale");
409
+ assert.strictEqual(compactionHeadState(owner, { sessionId, head: selected.id }), "current");
410
+ }),
411
+ testCase("round-trips a stale compaction notice with its usage", (owner) => {
412
+ const sessionId = "stale-notice";
413
+ append(owner, sessionId, "first");
414
+ const workId = sessionIds.work.generate();
415
+ const row = appendCompactionNotice(owner, {
416
+ sessionId,
417
+ workId,
418
+ trigger: "threshold",
419
+ result: "stale",
420
+ usage,
421
+ });
422
+ const stored = readSelectedPath(owner, { sessionId }).find(({ id }) => id === row.id);
423
+ assert.deepStrictEqual(stored?.entry, {
424
+ kind: "compaction_notice",
425
+ workId,
426
+ trigger: "threshold",
427
+ result: "stale",
428
+ usage,
429
+ });
430
+ // A notice that spent nothing records no usage.
431
+ const failed = appendCompactionNotice(owner, {
432
+ sessionId,
433
+ workId,
434
+ trigger: "threshold",
435
+ result: "failed",
436
+ diagnostic: "boom",
437
+ });
438
+ assert.ok(!("usage" in failed.entry), "A failed notice has no usage");
439
+ }),
440
+ testCase("keeps accepted work pending until a terminal settles it", (owner) => {
441
+ const first = admit(owner, prompt("pending", "pending-1", "one"));
442
+ const second = admit(owner, prompt("pending", "pending-2", "two"));
443
+ const pending = readPendingSessionCommands(owner, "pending", 10);
444
+ assert.strictEqual(pending.total, 2);
445
+ assert.deepStrictEqual(pending.commands.map(({ entry }) => entry.requestId), ["pending-1", "pending-2"]);
446
+ assert.ok(first.status === "accepted" && second.status === "accepted", "Both prompts are accepted");
447
+ }),
448
+ testCase("keeps explicit title overrides distinct from derived titles and replays clears without inbox changes", (owner) => {
449
+ const sessionId = "title-replay";
450
+ append(owner, sessionId, "Fern notes");
451
+ const before = readSessionInboxChanges(owner, 0, 64).latest;
452
+ // Equal-looking display text must still become an explicit override.
453
+ setSessionTitle(owner, sessionId, "Fern notes");
454
+ const overridden = readSessionInboxChanges(owner, before, 64);
455
+ assert.ok(overridden.latest > before);
456
+ assert.equal(readStoredSessionSummary(owner, sessionId)?.title, "Fern notes");
457
+ setSessionTitle(owner, sessionId, "Fern notes");
458
+ assert.deepStrictEqual(readSessionInboxChanges(owner, overridden.latest, 64), { rows: [], through: overridden.latest, latest: overridden.latest });
459
+ setSessionTitle(owner, sessionId, "Other notes");
460
+ assert.equal(readStoredSessionSummary(owner, sessionId)?.title, "Other notes");
461
+ setSessionTitle(owner, sessionId, null);
462
+ assert.equal(readStoredSessionSummary(owner, sessionId)?.title, "Fern notes");
463
+ const cleared = readSessionInboxChanges(owner, 0, 64).latest;
464
+ setSessionTitle(owner, sessionId, null);
465
+ assert.deepStrictEqual(readSessionInboxChanges(owner, cleared, 64), {
466
+ rows: [],
467
+ through: cleared,
468
+ latest: cleared,
469
+ });
470
+ }),
471
+ testCase("pages the inbox in one stable order past a full page", (owner) => {
472
+ // One Session past the 200-row inbox page, written in one transaction.
473
+ const total = 201;
474
+ owner.transactionSync(() => {
475
+ for (let index = 0; index < total; index += 1)
476
+ append(owner, `inbox-${String(index).padStart(3, "0")}`, "hello");
477
+ });
478
+ const seen = [];
479
+ let after;
480
+ do {
481
+ const page = Effect.runSync(readSessionInbox(owner, after));
482
+ seen.push(...page.sessions.map(({ sessionId }) => sessionId));
483
+ after = page.next;
484
+ } while (after !== undefined);
485
+ assert.strictEqual(seen.length, total);
486
+ assert.strictEqual(new Set(seen).size, total);
487
+ }),
488
+ ]);
489
+ }
@@ -1,7 +1,7 @@
1
1
  import * as JsonSchema from "effect/JsonSchema";
2
- import { ModelPreferenceSchema, parseModelPreference, parseModelPreferenceJson } from "./internal/model-preference.js";
2
+ import { ModelPreferenceSchema, reasoningLevels, parseModelPreference, parseModelPreferenceJson } from "./internal/model-preference.js";
3
3
  import type { ModelPreference } from "./internal/model-preference.js";
4
- export { ModelPreferenceSchema, parseModelPreference, parseModelPreferenceJson, };
4
+ export { ModelPreferenceSchema, reasoningLevels as modelReasoningLevels, parseModelPreference, parseModelPreferenceJson, };
5
5
  export type { ModelPreference } from "./internal/model-preference.js";
6
6
  /** JSON Schema generated from Shiitake's maintained model preference schema. */
7
7
  export declare const modelPreferenceJsonSchema: JsonSchema.JsonSchema;
@@ -1,7 +1,7 @@
1
1
  import * as JsonSchema from "effect/JsonSchema";
2
2
  import * as Schema from "effect/Schema";
3
- import { ModelPreferenceSchema, parseModelPreference, parseModelPreferenceJson, } from "./internal/model-preference.js";
4
- export { ModelPreferenceSchema, parseModelPreference, parseModelPreferenceJson, };
3
+ import { ModelPreferenceSchema, reasoningLevels, parseModelPreference, parseModelPreferenceJson, } from "./internal/model-preference.js";
4
+ export { ModelPreferenceSchema, reasoningLevels as modelReasoningLevels, parseModelPreference, parseModelPreferenceJson, };
5
5
  /** JSON Schema generated from Shiitake's maintained model preference schema. */
6
6
  export const modelPreferenceJsonSchema = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(ModelPreferenceSchema, {
7
7
  onExcessProperty: "error",
package/dist/node.js CHANGED
@@ -224,7 +224,7 @@ export async function openNodeShiitake(options) {
224
224
  host = runtime;
225
225
  const agent = await Shiitake.make({
226
226
  ...shiitakeOptions,
227
- store: fromNodeSqlite(opened),
227
+ store: { ...fromNodeSqlite(opened), storeName: `Store file ${path}` },
228
228
  runtime: runtime.runtime,
229
229
  });
230
230
  const routes = createShiitakeHttpRoutes((sessionId, meta) => (meta === undefined ? agent : agent.withMeta(meta)).session(sessionId), agent.listSessions, agent.createSession, agent.deleteSession, () => agent.extensions);
@@ -10,7 +10,7 @@ import * as Schema from "effect/Schema";
10
10
  import * as Scope from "effect/Scope";
11
11
  import * as Sink from "effect/Sink";
12
12
  import * as Stream from "effect/Stream";
13
- import { parseAccepted, parseCancelResult, parseForkedSession, parseCreatedSession, parseDeletedSession, parseExtensionOutput, parseExtensionStatus, parseExtensionStatuses, parseSessionFrame, parseSessionSnapshot, parseSessionSummaryPage, parseShiitakeError, } from "./client-wire.js";
13
+ import { parseAccepted, parseWithdrawResult, parseCancelResult, parseLatestPendingPrompt, parseForkedSession, parseCreatedSession, parseDeletedSession, parseExtensionOutput, parseExtensionStatus, parseExtensionStatuses, parseSessionFrame, parseSessionSnapshot, parseSessionSummaryPage, parseShiitakeError, } from "./client-wire.js";
14
14
  import { ShiitakeError } from "./errors.js";
15
15
  import { decodeClientReadOptions, decodeClientSessionCreationRequestId, decodeClientPageCursor, decodeClientSessionId, decodeClientSubscribeOptions, } from "./internal/client-boundary.js";
16
16
  import { toPromiseAgent } from "./internal/effect-client.js";
@@ -201,6 +201,12 @@ function makeRemoteEffectAgent(options) {
201
201
  }
202
202
  return Object.freeze({
203
203
  command,
204
+ withdraw: (requestId) => ensureOpen.pipe(Effect.andThen(request((signal) => fetch(`${addressedUrl}/withdraw`, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ requestId }),
208
+ signal,
209
+ }), parseWithdrawResult, "Request withdrawal"))),
204
210
  abort: command({ type: "abort" }),
205
211
  send: (text, sendOptions) => Effect.sync(() => sendOptions?.requestId ?? nanoid()).pipe(Effect.flatMap((requestId) => {
206
212
  const prompt = {
@@ -216,6 +222,7 @@ function makeRemoteEffectAgent(options) {
216
222
  : new ShiitakeError(error.boundary, error.cause, error.message, requestId)));
217
223
  })),
218
224
  cancel: (input) => command({ type: "cancel", ...input }),
225
+ latestPending: ensureOpen.pipe(Effect.andThen(request((signal) => fetch(`${addressedUrl}/pending/latest`, { signal }), parseLatestPendingPrompt, "Newest pending prompt"))),
219
226
  modelPreference: Object.freeze({
220
227
  read: ensureOpen.pipe(Effect.andThen(request((signal) => fetch(`${addressedUrl}/model-preference`, { signal }), parseModelPreferenceJson, "Model preference read"))),
221
228
  set: (preference) => ensureOpen.pipe(Effect.andThen(request((signal) => fetch(`${addressedUrl}/model-preference`, {