@celestea/studio 2.7.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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app.d.ts +111 -0
  3. package/dist/app.js +219 -0
  4. package/dist/auth/api-token.d.ts +76 -0
  5. package/dist/auth/api-token.js +172 -0
  6. package/dist/auth/htpasswd.d.ts +22 -0
  7. package/dist/auth/htpasswd.js +61 -0
  8. package/dist/auth/index.d.ts +14 -0
  9. package/dist/auth/index.js +14 -0
  10. package/dist/auth/page.d.ts +19 -0
  11. package/dist/auth/page.js +73 -0
  12. package/dist/auth/rate-limit.d.ts +27 -0
  13. package/dist/auth/rate-limit.js +47 -0
  14. package/dist/auth/token.d.ts +49 -0
  15. package/dist/auth/token.js +112 -0
  16. package/dist/config.d.ts +78 -0
  17. package/dist/config.js +72 -0
  18. package/dist/deployment.d.ts +92 -0
  19. package/dist/deployment.js +154 -0
  20. package/dist/fake-runtime-adapter.d.ts +37 -0
  21. package/dist/fake-runtime-adapter.js +276 -0
  22. package/dist/handlers/auth.d.ts +24 -0
  23. package/dist/handlers/auth.js +155 -0
  24. package/dist/handlers/common.d.ts +76 -0
  25. package/dist/handlers/common.js +127 -0
  26. package/dist/handlers/config-shape.d.ts +76 -0
  27. package/dist/handlers/config-shape.js +227 -0
  28. package/dist/handlers/config.d.ts +26 -0
  29. package/dist/handlers/config.js +161 -0
  30. package/dist/handlers/context-shape.d.ts +33 -0
  31. package/dist/handlers/context-shape.js +59 -0
  32. package/dist/handlers/dialog.d.ts +22 -0
  33. package/dist/handlers/dialog.js +292 -0
  34. package/dist/handlers/exec.d.ts +22 -0
  35. package/dist/handlers/exec.js +131 -0
  36. package/dist/handlers/fs-read.d.ts +63 -0
  37. package/dist/handlers/fs-read.js +159 -0
  38. package/dist/handlers/fs.d.ts +57 -0
  39. package/dist/handlers/fs.js +153 -0
  40. package/dist/handlers/grants-shape.d.ts +45 -0
  41. package/dist/handlers/grants-shape.js +130 -0
  42. package/dist/handlers/grants.d.ts +21 -0
  43. package/dist/handlers/grants.js +267 -0
  44. package/dist/handlers/health.d.ts +37 -0
  45. package/dist/handlers/health.js +139 -0
  46. package/dist/handlers/index.d.ts +40 -0
  47. package/dist/handlers/index.js +85 -0
  48. package/dist/handlers/permissions.d.ts +9 -0
  49. package/dist/handlers/permissions.js +183 -0
  50. package/dist/handlers/plugins.d.ts +18 -0
  51. package/dist/handlers/plugins.js +23 -0
  52. package/dist/handlers/prompts.d.ts +12 -0
  53. package/dist/handlers/prompts.js +120 -0
  54. package/dist/handlers/providers.d.ts +12 -0
  55. package/dist/handlers/providers.js +178 -0
  56. package/dist/handlers/questions.d.ts +23 -0
  57. package/dist/handlers/questions.js +115 -0
  58. package/dist/handlers/session-model.d.ts +38 -0
  59. package/dist/handlers/session-model.js +105 -0
  60. package/dist/handlers/session-move.d.ts +22 -0
  61. package/dist/handlers/session-move.js +188 -0
  62. package/dist/handlers/session-tools.d.ts +19 -0
  63. package/dist/handlers/session-tools.js +75 -0
  64. package/dist/handlers/sessions.d.ts +32 -0
  65. package/dist/handlers/sessions.js +242 -0
  66. package/dist/handlers/usage.d.ts +26 -0
  67. package/dist/handlers/usage.js +77 -0
  68. package/dist/handlers/worker.d.ts +12 -0
  69. package/dist/handlers/worker.js +82 -0
  70. package/dist/handlers/workspaces.d.ts +12 -0
  71. package/dist/handlers/workspaces.js +88 -0
  72. package/dist/harness.test-util.d.ts +101 -0
  73. package/dist/harness.test-util.js +176 -0
  74. package/dist/index.d.ts +33 -0
  75. package/dist/index.js +33 -0
  76. package/dist/main.d.ts +13 -0
  77. package/dist/main.js +35 -0
  78. package/dist/plugins.d.ts +105 -0
  79. package/dist/plugins.js +97 -0
  80. package/dist/question-registry.d.ts +124 -0
  81. package/dist/question-registry.js +212 -0
  82. package/dist/question-rows.d.ts +32 -0
  83. package/dist/question-rows.js +43 -0
  84. package/dist/replay/compare.d.ts +47 -0
  85. package/dist/replay/compare.js +89 -0
  86. package/dist/replay/e2e-replay.d.ts +46 -0
  87. package/dist/replay/e2e-replay.js +91 -0
  88. package/dist/replay/expect-compact.d.ts +31 -0
  89. package/dist/replay/expect-compact.js +92 -0
  90. package/dist/replay/fixtures.d.ts +52 -0
  91. package/dist/replay/fixtures.js +53 -0
  92. package/dist/replay/host.d.ts +32 -0
  93. package/dist/replay/host.js +62 -0
  94. package/dist/replay/index.d.ts +16 -0
  95. package/dist/replay/index.js +16 -0
  96. package/dist/replay/probes.d.ts +55 -0
  97. package/dist/replay/probes.js +225 -0
  98. package/dist/replay/report.d.ts +13 -0
  99. package/dist/replay/report.js +103 -0
  100. package/dist/replay/session-e2e.d.ts +44 -0
  101. package/dist/replay/session-e2e.js +91 -0
  102. package/dist/routes.d.ts +47 -0
  103. package/dist/routes.js +59 -0
  104. package/dist/runtime/attachments-llm.d.ts +13 -0
  105. package/dist/runtime/attachments-llm.js +37 -0
  106. package/dist/runtime/boot-recovery.d.ts +32 -0
  107. package/dist/runtime/boot-recovery.js +71 -0
  108. package/dist/runtime/context-snapshot.d.ts +59 -0
  109. package/dist/runtime/context-snapshot.js +113 -0
  110. package/dist/runtime/engine-grants.d.ts +121 -0
  111. package/dist/runtime/engine-grants.js +338 -0
  112. package/dist/runtime/engine-permissions.d.ts +29 -0
  113. package/dist/runtime/engine-permissions.js +92 -0
  114. package/dist/runtime/engine-plugins.d.ts +189 -0
  115. package/dist/runtime/engine-plugins.js +292 -0
  116. package/dist/runtime/engine-profile.d.ts +28 -0
  117. package/dist/runtime/engine-profile.js +88 -0
  118. package/dist/runtime/engine-session.d.ts +9 -0
  119. package/dist/runtime/engine-session.js +9 -0
  120. package/dist/runtime/fallback-contract.d.ts +32 -0
  121. package/dist/runtime/fallback-contract.js +10 -0
  122. package/dist/runtime/fallback-host.d.ts +134 -0
  123. package/dist/runtime/fallback-host.js +299 -0
  124. package/dist/runtime/host-autowake.d.ts +79 -0
  125. package/dist/runtime/host-autowake.js +86 -0
  126. package/dist/runtime/image-downgrade.d.ts +49 -0
  127. package/dist/runtime/image-downgrade.js +90 -0
  128. package/dist/runtime/inbox-message.d.ts +11 -0
  129. package/dist/runtime/inbox-message.js +20 -0
  130. package/dist/runtime/index.d.ts +17 -0
  131. package/dist/runtime/index.js +17 -0
  132. package/dist/runtime/ledger-view.d.ts +40 -0
  133. package/dist/runtime/ledger-view.js +56 -0
  134. package/dist/runtime/llm-assembly.d.ts +66 -0
  135. package/dist/runtime/llm-assembly.js +114 -0
  136. package/dist/runtime/offline-llm.d.ts +71 -0
  137. package/dist/runtime/offline-llm.js +132 -0
  138. package/dist/runtime/provider-target.d.ts +10 -0
  139. package/dist/runtime/provider-target.js +10 -0
  140. package/dist/runtime/question-host.d.ts +33 -0
  141. package/dist/runtime/question-host.js +41 -0
  142. package/dist/runtime/question-view.d.ts +48 -0
  143. package/dist/runtime/question-view.js +76 -0
  144. package/dist/runtime/real-runtime-adapter.d.ts +95 -0
  145. package/dist/runtime/real-runtime-adapter.js +553 -0
  146. package/dist/runtime/recovery-audit.d.ts +81 -0
  147. package/dist/runtime/recovery-audit.js +95 -0
  148. package/dist/runtime/recovery-view.d.ts +36 -0
  149. package/dist/runtime/recovery-view.js +63 -0
  150. package/dist/runtime/session-compose.d.ts +235 -0
  151. package/dist/runtime/session-compose.js +350 -0
  152. package/dist/runtime/session-grants.d.ts +36 -0
  153. package/dist/runtime/session-grants.js +69 -0
  154. package/dist/runtime/session-lifecycle.d.ts +40 -0
  155. package/dist/runtime/session-lifecycle.js +86 -0
  156. package/dist/runtime/session-publisher.d.ts +40 -0
  157. package/dist/runtime/session-publisher.js +48 -0
  158. package/dist/runtime/session-release.d.ts +53 -0
  159. package/dist/runtime/session-release.js +80 -0
  160. package/dist/runtime/test-util.d.ts +78 -0
  161. package/dist/runtime/test-util.js +162 -0
  162. package/dist/runtime/watchdog-view.d.ts +33 -0
  163. package/dist/runtime/watchdog-view.js +45 -0
  164. package/dist/runtime/worker-bridge.d.ts +76 -0
  165. package/dist/runtime/worker-bridge.js +156 -0
  166. package/dist/runtime/worker-live.d.ts +22 -0
  167. package/dist/runtime/worker-live.js +25 -0
  168. package/dist/runtime/worker-recovery.d.ts +35 -0
  169. package/dist/runtime/worker-recovery.js +64 -0
  170. package/dist/runtime/worker-table.d.ts +58 -0
  171. package/dist/runtime/worker-table.js +70 -0
  172. package/dist/runtime-adapter.d.ts +421 -0
  173. package/dist/runtime-adapter.js +76 -0
  174. package/dist/serial-queue.d.ts +16 -0
  175. package/dist/serial-queue.js +22 -0
  176. package/dist/server.d.ts +44 -0
  177. package/dist/server.js +119 -0
  178. package/dist/settings.d.ts +23 -0
  179. package/dist/settings.js +31 -0
  180. package/dist/sse.d.ts +67 -0
  181. package/dist/sse.js +186 -0
  182. package/dist/static.d.ts +20 -0
  183. package/dist/static.js +120 -0
  184. package/dist/store/builtin-sections.d.ts +52 -0
  185. package/dist/store/builtin-sections.js +118 -0
  186. package/dist/store/celestea-home.d.ts +12 -0
  187. package/dist/store/celestea-home.js +11 -0
  188. package/dist/store/fs-json.d.ts +51 -0
  189. package/dist/store/fs-json.js +119 -0
  190. package/dist/store/grants-audit.d.ts +84 -0
  191. package/dist/store/grants-audit.js +109 -0
  192. package/dist/store/grants-service.d.ts +44 -0
  193. package/dist/store/grants-service.js +41 -0
  194. package/dist/store/grants-tokens.d.ts +62 -0
  195. package/dist/store/grants-tokens.js +105 -0
  196. package/dist/store/grants.d.ts +113 -0
  197. package/dist/store/grants.js +238 -0
  198. package/dist/store/index.d.ts +36 -0
  199. package/dist/store/index.js +36 -0
  200. package/dist/store/mode.d.ts +35 -0
  201. package/dist/store/mode.js +41 -0
  202. package/dist/store/permissions.d.ts +61 -0
  203. package/dist/store/permissions.js +139 -0
  204. package/dist/store/prompts-compose.d.ts +56 -0
  205. package/dist/store/prompts-compose.js +62 -0
  206. package/dist/store/prompts-template.d.ts +26 -0
  207. package/dist/store/prompts-template.js +99 -0
  208. package/dist/store/prompts.d.ts +142 -0
  209. package/dist/store/prompts.js +273 -0
  210. package/dist/store/provider-probe.d.ts +81 -0
  211. package/dist/store/provider-probe.js +154 -0
  212. package/dist/store/providers.d.ts +97 -0
  213. package/dist/store/providers.js +219 -0
  214. package/dist/store/result.d.ts +29 -0
  215. package/dist/store/result.js +30 -0
  216. package/dist/store/session-id.d.ts +119 -0
  217. package/dist/store/session-id.js +220 -0
  218. package/dist/store/session-meta.d.ts +41 -0
  219. package/dist/store/session-meta.js +66 -0
  220. package/dist/store/session-ops.d.ts +109 -0
  221. package/dist/store/session-ops.js +264 -0
  222. package/dist/store/session-tools.d.ts +42 -0
  223. package/dist/store/session-tools.js +69 -0
  224. package/dist/store/sessions.d.ts +162 -0
  225. package/dist/store/sessions.js +307 -0
  226. package/dist/store/validate.d.ts +13 -0
  227. package/dist/store/validate.js +38 -0
  228. package/dist/store/workspaces.d.ts +67 -0
  229. package/dist/store/workspaces.js +228 -0
  230. package/dist/user-questions.d.ts +58 -0
  231. package/dist/user-questions.js +157 -0
  232. package/dist/version.d.ts +14 -0
  233. package/dist/version.js +52 -0
  234. package/package.json +38 -0
  235. package/webdist/assets/index-Bngs7gUm.js +98 -0
  236. package/webdist/assets/index-Bu6ci_rN.css +1 -0
  237. package/webdist/assets/katex-CCbS2qAY.js +257 -0
  238. package/webdist/assets/mhchem-CnUN8HwY.js +1 -0
  239. package/webdist/build-meta.json +7 -0
  240. package/webdist/index.html +261 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * SessionComposer — `compose()` for ONE session (W513).
3
+ *
4
+ * Every session runtime is one self-consistent generation: its own profile
5
+ * snapshot (base profile + the session's `session.json` model override), its own
6
+ * session binding (`<dir>/cli-main.jsonl` or an in-memory log when detached),
7
+ * its own usage tracker, its own agent loop instance and its own worker
8
+ * registry. Nothing is shared but the process (and the LLM seam factory), which
9
+ * is exactly what makes two sessions unable to see each other's history.
10
+ *
11
+ * The module also owns the resource caps and the two error channels the host
12
+ * maps onto HTTP 503, so the adapter itself stays about the HTTP contract.
13
+ */
14
+ import { createUsageTracker, DefaultAgentLoop } from "@celestea/agent-loop";
15
+ import { listSkills, memoryContextOf, readLayers, renderSkillCatalog } from "@celestea/core";
16
+ import { createSessionInbox } from "@celestea/runtime";
17
+ import { createLedgerLlm, createUsageLedger, hostOf, HOST_SESSION_ID, } from "@celestea/runtime";
18
+ import { InMemorySessionLog } from "@celestea/session";
19
+ import { compose, llmSummarizer, SessionCapacityError, TurnCapacityError, } from "@celestea/runtime";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { CapacityError } from "../runtime-adapter.js";
23
+ import { bindingFor, closeLog, workerSessionPrefix } from "./engine-session.js";
24
+ import { DEFAULT_SESSION_MODE, effectiveMode } from "../store/mode.js";
25
+ import { enginePlugins } from "./engine-plugins.js";
26
+ import { questionAnsweredRow, questionAskedRow } from "../question-rows.js";
27
+ import { EMPTY_GRANTS } from "./engine-grants.js";
28
+ import { createEngineLlm } from "./llm-assembly.js";
29
+ import { workerTablePath } from "./worker-table.js";
30
+ import { ATTACHMENTS_DIRNAME, createAttachmentStore } from "@celestea/tools";
31
+ import { createImageDowngradeLlm } from "@celestea/llm";
32
+ import { withAttachments } from "./attachments-llm.js";
33
+ /** W510 resource caps (overridable through the adapter options or the env). */
34
+ export const MAX_LIVE_SESSIONS = 4;
35
+ export const MAX_CONCURRENT_TURNS = 2;
36
+ export const SESSION_IDLE_TTL_MS = 15 * 60 * 1_000;
37
+ /** Non-negative integer from the environment, else the frozen default. */
38
+ export function limitFromEnv(env, name, fallback) {
39
+ const raw = env[name];
40
+ if (raw === undefined || raw.trim() === "")
41
+ return fallback;
42
+ const value = Number.parseInt(raw, 10);
43
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
44
+ }
45
+ /** Shut one instance down and close its log descriptor (order matters). */
46
+ export async function disposeRuntime(runtime) {
47
+ const log = runtime.session;
48
+ await runtime.shutdown();
49
+ closeLog(log);
50
+ runtime.release();
51
+ }
52
+ /** Wrap the registry caps into the ONE error the HTTP layer maps to 503. */
53
+ export function capacityErrorOf(e) {
54
+ if (e instanceof SessionCapacityError)
55
+ return new CapacityError(`too many live sessions (limit ${e.limit})`);
56
+ if (e instanceof TurnCapacityError)
57
+ return new CapacityError(`too many concurrent turns (limit ${e.limit})`);
58
+ return e;
59
+ }
60
+ export class SessionComposer {
61
+ opts;
62
+ memoryLogs = new Map();
63
+ constructor(opts) {
64
+ this.opts = opts;
65
+ }
66
+ /** Compose one session generation (the registry's build factory). */
67
+ compose(sessionId, dir) {
68
+ const profile = this.profileFor(sessionId);
69
+ // W804 (multimodal P0 section 5): the session's attachment store. It lives
70
+ // INSIDE the session directory, so trash/archive/delete carry it along. The
71
+ // DETACHED generation (dir === null, the face /api/tools and the default
72
+ // prompt read) gets a host-level store so read_image is part of the SAME face
73
+ // every session's prompt advertises; a session-less turn is the only caller
74
+ // that could ever write there.
75
+ const attachments = createAttachmentStore(dir === null ? join(tmpdir(), "celestea-detached-attachments") : join(dir, ATTACHMENTS_DIRNAME));
76
+ // Optimistic default (section 7.1): only an EXPLICIT input_modalities without
77
+ // "image" disables the read_image gate; an unknown model stays optimistic.
78
+ const modalities = this.opts.modelInputModalities?.(profile.model) ?? null;
79
+ const imageInputAllowed = modalities === null ? true : modalities.includes("image");
80
+ // W768: the session's OWN workspace, taken from the same resolution the
81
+ // system prompt renders (the host's `resolveSession` hook). A session with no
82
+ // resolvable workspace keeps the process env posture — never a failure.
83
+ const workspace = sessionId === null ? null : (this.opts.resolveSession?.(sessionId)?.workspace ?? null);
84
+ // W884 + F3: the engine-owned TURN CONTEXT. The skill catalog (name +
85
+ // description ONLY) and the workspace MEMORY.md are re-read at EVERY turn
86
+ // start from the SAME workspace the sandbox/guard use (W768) and injected
87
+ // as durable user-role history. Neither is ever put in the system prompt.
88
+ // A workspace with neither produces NO rows at all (zero cost), and a
89
+ // detached generation (no workspace) never attaches the provider at all.
90
+ const turnContext = workspace === null
91
+ ? undefined
92
+ : () => {
93
+ const rows = [];
94
+ const catalog = renderSkillCatalog(listSkills(readLayers(workspace.path, { env: this.opts.env })));
95
+ if (catalog !== null)
96
+ rows.push({ text: catalog, origin: "skill" });
97
+ const memory = memoryContextOf(workspace.path, { env: this.opts.env });
98
+ if (memory !== null)
99
+ rows.push({ text: memory, origin: "memory" });
100
+ return rows;
101
+ };
102
+ const reader = this.opts.grants;
103
+ const read = reader?.read(sessionId, dir) ?? { grants: EMPTY_GRANTS, warnings: [] };
104
+ // W728: the ledger must exist before the Llm wrapper (every step books).
105
+ const ledger = this.usageLedger(sessionId, dir);
106
+ // W783: the question wiring of THIS generation. The runtime handle does not
107
+ // exist until `compose()` below returns, so the wiring reaches it through a
108
+ // holder it fills in immediately afterwards — the same late-binding the
109
+ // `isLive` probe and the log write both need.
110
+ const questionHolder = { runtime: null };
111
+ const questions = this.questionWiring(sessionId, questionHolder);
112
+ const engine = enginePlugins({
113
+ profile,
114
+ // W791 (P1, §5.2 #2): the mode decided at compose time. The DETACHED
115
+ // generation never asks the hook (its id is not addressable), a session
116
+ // without a declared mode reads as `standard` (K8), and both compose the
117
+ // whole registry — so nothing about a pre-P1 generation changes.
118
+ mode: sessionId === null ? DEFAULT_SESSION_MODE : effectiveMode(this.opts.sessionMode?.(sessionId) ?? null),
119
+ workspace: workspace === null ? null : { workspace: workspace.path },
120
+ ...(questions === null ? {} : { questions }),
121
+ attachments,
122
+ imageInputAllowed,
123
+ llm: this.engineLlm(sessionId, profile, ledger, attachments),
124
+ workers: null, // the workers plugin registers the three tools, in compose order
125
+ ...(this.opts.disclosure === undefined || sessionId === null ? {} : { disclosure: this.opts.disclosure }),
126
+ ...(this.opts.tools === undefined ? {} : { tools: this.opts.tools }),
127
+ ...(this.opts.sandbox === undefined ? {} : { sandbox: this.opts.sandbox }),
128
+ ...(this.opts.guard === undefined ? {} : { guard: this.opts.guard }),
129
+ grants: read.grants,
130
+ ...(reader === undefined ? {} : { audit: reader.audit(sessionId) }),
131
+ env: this.opts.env,
132
+ });
133
+ // After the boundary is built: audit the generation and spend one-shots, so
134
+ // THIS turn keeps its grants and the next one sees the consumption.
135
+ reader?.onComposed(sessionId, dir, read);
136
+ const usage = createUsageTracker();
137
+ const hooks = this.opts.sessionHooks?.(sessionId) ?? {};
138
+ const composed = compose({
139
+ profile,
140
+ plugins: engine.plugins,
141
+ sessionBinding: this.bindingTo(sessionId, dir),
142
+ // W855 #1: reap detached `run_shell background:true` children on shutdown.
143
+ // The composer holds the registry from `engine.tools`; runtime is L2 and
144
+ // may not import @celestea/tools, so the hook is wired HERE (host side).
145
+ shutdownHooks: [() => engine.tools.processes.dispose()],
146
+ usage,
147
+ ...(ledger === null ? {} : { ledger }),
148
+ inbox: hooks.inbox ?? createSessionInbox(),
149
+ ...(hooks.onInjected === undefined ? {} : { onInjected: hooks.onInjected }),
150
+ ...(turnContext === undefined ? {} : { turnContext }),
151
+ loopFactory: (bindings) => {
152
+ // W806: the turn boundary is the ONLY place the disclosed set may move.
153
+ engine.tools.disclosure.beginTurn();
154
+ return new DefaultAgentLoop(bindings.config, {
155
+ signal: bindings.signal,
156
+ sink: bindings.sink,
157
+ usage,
158
+ ...(bindings.injections === undefined ? {} : { injections: bindings.injections }),
159
+ });
160
+ },
161
+ workers: this.workerWiring(sessionId, profile),
162
+ // W740: the watchdog settings come from the process environment; the
163
+ // composition root reads them and registers the stop hook with the sweep.
164
+ env: this.opts.env,
165
+ ...(this.opts.watchdog === undefined ? {} : { watchdog: this.opts.watchdog }),
166
+ ...(this.opts.now === undefined ? {} : { now: this.opts.now }),
167
+ });
168
+ // W783: bind the just-composed runtime into the question wiring, so
169
+ // `isLive` and the `user_question` log row address THIS generation.
170
+ questionHolder.runtime = composed;
171
+ return composed;
172
+ }
173
+ /**
174
+ * W783: the user-question wiring of ONE session generation, or null when the
175
+ * host mounted no table (the tool is then not offered to the model at all).
176
+ *
177
+ * The bus is the SESSION's own (`compose()` provides it), filled in by the
178
+ * plugin body: an answerer chain per generation is what stops a question asked
179
+ * in one session from being answered into another.
180
+ */
181
+ questionWiring(sessionId, holder) {
182
+ const registry = this.opts.questionRegistry;
183
+ if (registry === undefined || registry === null)
184
+ return null;
185
+ return {
186
+ registry,
187
+ sessionId,
188
+ bus: { current: null },
189
+ isLive: () => holder.runtime !== null && !holder.runtime.isReleased,
190
+ publish: (question) => this.opts.publishQuestion?.(sessionId, question),
191
+ record: (question) => holder.runtime?.session.append(questionAskedRow(question)),
192
+ recordAnswer: (requestId, answers, timedOut) => holder.runtime?.session.append(questionAnsweredRow(requestId, answers, timedOut)),
193
+ };
194
+ }
195
+ /**
196
+ * The session's ledger, or null when the host did not wire one. The session
197
+ * label is the file's self-description (`<workspace>/<session>`, §3.2.1).
198
+ *
199
+ * W878: `compose(sessionId, dir)` already carries the trusted id, so the label
200
+ * uses it directly and only falls back to `HOST_SESSION_ID` when the id is
201
+ * null (the detached generation). `dir` is kept in the signature for the
202
+ * caller but is deliberately no longer a source of the label.
203
+ */
204
+ usageLedger(sessionId, dir) {
205
+ void dir;
206
+ const file = this.opts.ledgerFile;
207
+ if (file === undefined || file === null)
208
+ return null;
209
+ return createUsageLedger({ session: sessionId ?? HOST_SESSION_ID, file });
210
+ }
211
+ /**
212
+ * E §4 P1 (W785): the ONE place a composed generation decides which `Llm` it
213
+ * runs on. Fallback OFF (or unwired) = the W728 path unchanged (ledger wrapper
214
+ * around the raw seam). Fallback ON = the decorator, which books one ledger
215
+ * row per ATTEMPT and hands the switch to the next target.
216
+ */
217
+ engineLlm(sessionId, profile, ledger, attachments) {
218
+ const inner = this.llmFactory()(profile);
219
+ const wrapped = this.opts.fallback?.wrap({
220
+ inner,
221
+ profile,
222
+ sessionId,
223
+ steps: ledger,
224
+ provider: this.opts.providerLabel ?? null,
225
+ }) ?? null;
226
+ const observed = wrapped ?? this.stepObservedLlm(inner, profile, ledger);
227
+ // W804: resolve image references to a REQUEST-scoped data-URL table (inner),
228
+ // then downgrade once on an "image unsupported" 400 (outer). The downgrade
229
+ // decorator is provider-seam typed; it only forwards streams, so the cast is
230
+ // a type-level bridge (same pattern as fallback-host.ts).
231
+ const resolved = withAttachments(observed, attachments);
232
+ return createImageDowngradeLlm({
233
+ inner: resolved,
234
+ onDowngrade: (info) => this.opts.onModelDowngrade?.(sessionId, info),
235
+ // W855: the SAME per-model modality gate that feeds read_image. It is
236
+ // evaluated against the request's own req.model (inside the decorator),
237
+ // so a model switch is never stale. null (unconfigured) = optimistic:
238
+ // images allowed; only an EXPLICIT list without "image" is text-only.
239
+ isTextOnly: (requestModel) => {
240
+ const modalities = this.opts.modelInputModalities?.(requestModel) ?? null;
241
+ return modalities !== null && !modalities.includes("image");
242
+ },
243
+ });
244
+ }
245
+ /**
246
+ * W728 §3 P0: wrap the engine `Llm` so every model step books one ledger row
247
+ * (success, failure and retry alike). The wrapper lives in the composed
248
+ * Context, so worker-driven calls go through it as well; the summarizer path
249
+ * is separate (`summarizer()`, a P1 concern).
250
+ */
251
+ stepObservedLlm(llm, profile, ledger) {
252
+ if (ledger === null)
253
+ return llm;
254
+ return createLedgerLlm({
255
+ inner: llm,
256
+ sink: ledger,
257
+ provider: this.opts.providerLabel ?? null,
258
+ model: profile.model,
259
+ base_url_host: hostOf(profile.base_url),
260
+ });
261
+ }
262
+ /**
263
+ * Base profile + the session's own `session.json` overrides (model AND, since
264
+ * W729, the mode-dependent system prompt). This is the ONE place a session's
265
+ * instance profile is decided, so two sessions in the same process can differ
266
+ * in prompt without either one seeing the other's.
267
+ */
268
+ profileFor(sessionId) {
269
+ const base = this.opts.baseProfile();
270
+ const overrides = this.sessionOverrides(sessionId);
271
+ return overrides === null ? base : { ...base, ...overrides };
272
+ }
273
+ /** The session's profile overrides; `null` when it declares none. */
274
+ sessionOverrides(sessionId) {
275
+ if (sessionId === null)
276
+ return null;
277
+ const out = {};
278
+ const model = this.opts.sessionModel?.(sessionId) ?? "";
279
+ if (model !== "")
280
+ out.model = model;
281
+ const prompt = this.opts.sessionSystemPrompt?.(sessionId) ?? "";
282
+ if (prompt !== "")
283
+ out.system_prompt = prompt;
284
+ return Object.keys(out).length === 0 ? null : out;
285
+ }
286
+ /** The compact summarizer of the CURRENT base profile. */
287
+ summarizer() {
288
+ const profile = this.opts.baseProfile();
289
+ const factory = this.opts.summarize;
290
+ if (factory !== undefined)
291
+ return factory(profile);
292
+ return llmSummarizer({ llm: this.llmFactory()(profile), model: profile.model });
293
+ }
294
+ /**
295
+ * Worker wiring: ONE registry per session instance (W513, design D7), so a
296
+ * receipt returns to the session that spawned the worker and `worker:<sid>`
297
+ * ids stay unique through the session-derived prefix. W787: the table is
298
+ * PERSISTED by default at `workerRegistryPath()` (`<data dir>/
299
+ * worker-registry.tsv`; `CELESTEA_WORKER_REGISTRY` overrides). Only an
300
+ * explicit `tsvPath: null` is an in-memory table, so the default no
301
+ * longer collides the host with the fleet's shared `/tmp/registry.tsv`.
302
+ */
303
+ workerWiring(sessionId, profile) {
304
+ if (this.opts.workers === false)
305
+ return false;
306
+ return {
307
+ tsvPath: this.workerRegistryPath(),
308
+ resultsDir: this.opts.resultsDir ?? join(process.cwd(), "worker-results"),
309
+ sourceLabel: "celestea.studio-ts",
310
+ logFactory: () => new InMemorySessionLog(),
311
+ hostSessionId: sessionId ?? "cli-main",
312
+ sessionIdPrefix: workerSessionPrefix(sessionId),
313
+ hostModel: profile.model,
314
+ // W729 §2.3: workers inherit the spawning session's mode by default.
315
+ hostMode: sessionId === null ? null : (this.opts.sessionMode?.(sessionId) ?? null),
316
+ };
317
+ }
318
+ /** E §2.3 P0 ①: the configured table path (see `worker-table.ts` for the rules). */
319
+ workerRegistryPath() {
320
+ return workerTablePath({
321
+ env: this.opts.env,
322
+ ...(this.opts.dataDir === undefined ? {} : { dataDir: this.opts.dataDir }),
323
+ resultsDir: this.opts.resultsDir ?? join(process.cwd(), "worker-results"),
324
+ ...(this.opts.workerRegistryPath === undefined ? {} : { override: this.opts.workerRegistryPath }),
325
+ });
326
+ }
327
+ llmFactory() {
328
+ return this.opts.llm ?? ((profile) => createEngineLlm(profile, this.opts.env));
329
+ }
330
+ bindingTo(sessionId, dir) {
331
+ const target = dir === null || sessionId === null ? null : { sessionId, dir };
332
+ return bindingFor(sessionId, target, this.memoryLogs, {
333
+ ...(this.opts.checkpoint ?? {}),
334
+ onDegraded: (info) => this.noteDegraded(info),
335
+ });
336
+ }
337
+ /**
338
+ * E §1.3 P1 ③: a session log that refused a write is reported to the audit
339
+ * channel (the sidecar already carries the sticky counter, §1.2.2). One line
340
+ * per session instance — the store fires this at most once.
341
+ */
342
+ noteDegraded(info) {
343
+ this.opts.recoveryAudit?.write({
344
+ event: "log_degraded",
345
+ session: info.session,
346
+ count: info.count,
347
+ detail: `session log writeErrorCount=${info.count} (disk and memory diverged)`,
348
+ });
349
+ }
350
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The session grant boundary at COMPOSE time (W516 §4.2, §4.4).
3
+ *
4
+ * One instance per session is composed per turn boundary, so this is where the
5
+ * session's widenings are read, audited and (for one-shot entries) spent. "Read
6
+ * at compose" is what makes a grant land in the NEXT turn and never inside the
7
+ * running one: the instance a turn runs on keeps the boundary it was composed
8
+ * with, whatever happens to `grants.json` meanwhile.
9
+ *
10
+ * Honest limits of this implementation (also listed in the W516 report): `use`
11
+ * is sampled once per composed generation (i.e. per turn boundary) rather than
12
+ * once per sandbox spawn, and a one-shot entry is spent at compose time — the
13
+ * safe direction (it can only be lost earlier, never used twice).
14
+ */
15
+ import { type EffectiveGrants } from "./engine-grants.js";
16
+ import type { EngineGrantAudit } from "./engine-grants.js";
17
+ export interface GrantsReadResult {
18
+ grants: EffectiveGrants;
19
+ warnings: string[];
20
+ }
21
+ export interface SessionGrantsReader {
22
+ /** Effective grants of one session directory (never throws — §4.1). */
23
+ read(sessionId: string | null, dir: string | null): GrantsReadResult;
24
+ /** Audit the boundary + spend one-shot entries; called once per compose. */
25
+ onComposed(sessionId: string | null, dir: string | null, read: GrantsReadResult): void;
26
+ /** An audit sink already bound to one session. */
27
+ audit(sessionId: string | null): EngineGrantAudit;
28
+ /** Await in-flight platform-audit deliveries (tests / shutdown). */
29
+ flush(): Promise<void>;
30
+ }
31
+ export interface SessionGrantsOptions {
32
+ dataDir: string;
33
+ env: NodeJS.ProcessEnv;
34
+ now?: () => number;
35
+ }
36
+ export declare function createSessionGrants(opts: SessionGrantsOptions): SessionGrantsReader;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The session grant boundary at COMPOSE time (W516 §4.2, §4.4).
3
+ *
4
+ * One instance per session is composed per turn boundary, so this is where the
5
+ * session's widenings are read, audited and (for one-shot entries) spent. "Read
6
+ * at compose" is what makes a grant land in the NEXT turn and never inside the
7
+ * running one: the instance a turn runs on keeps the boundary it was composed
8
+ * with, whatever happens to `grants.json` meanwhile.
9
+ *
10
+ * Honest limits of this implementation (also listed in the W516 report): `use`
11
+ * is sampled once per composed generation (i.e. per turn boundary) rather than
12
+ * once per sandbox spawn, and a one-shot entry is spent at compose time — the
13
+ * safe direction (it can only be lost earlier, never used twice).
14
+ */
15
+ import { effectiveGrantsOf, expiredGrants } from "./engine-grants.js";
16
+ import { GrantsAuditWriter } from "../store/grants-audit.js";
17
+ import { isExpired, readGrantsFile, writeGrantsFile } from "../store/grants.js";
18
+ /** Caps whose use changes the process environment → always sampled (§4.4). */
19
+ const HEAVY_CAPS = ["network", "unsandboxed"];
20
+ export function createSessionGrants(opts) {
21
+ const writer = new GrantsAuditWriter({ dataDir: opts.dataDir, env: opts.env, now: opts.now ?? Date.now });
22
+ const audit = (sessionId) => (event) => writer.write({ session: sessionId ?? "", ...event });
23
+ const ctx = { env: opts.env, now: opts.now ?? Date.now };
24
+ return {
25
+ read: (sessionId, dir) => effectiveGrantsOf(dir, sessionId, ctx.env, Math.floor(ctx.now() / 1000)),
26
+ audit,
27
+ flush: () => writer.flush(),
28
+ onComposed: (sessionId, dir, result) => recordComposed(writer, sessionId, dir, result, ctx),
29
+ };
30
+ }
31
+ /** Warnings → audit; heavyweight caps → `use`; expired / one-shot entries. */
32
+ function recordComposed(writer, sessionId, dir, result, ctx) {
33
+ const session = sessionId ?? "";
34
+ const sink = (event) => writer.write({ session, ...event });
35
+ const seconds = Math.floor(ctx.now() / 1000);
36
+ for (const warning of result.warnings) {
37
+ const event = warning.startsWith("grants_unreadable") ? "grants_unreadable" : "deny";
38
+ sink({ event, reason: warning });
39
+ }
40
+ for (const source of result.grants.sources) {
41
+ if (!HEAVY_CAPS.includes(source.cap))
42
+ continue;
43
+ sink({ event: "use", cap: source.cap, grant_id: source.grantId, detail: "active for the composed session instance" });
44
+ }
45
+ // W878: the file is self-describing, so validate it against the TRUSTED id
46
+ // (never a path inference). A null id with a real dir reads nothing.
47
+ const read = dir === null || sessionId === null ? null : readGrantsFile(dir, sessionId);
48
+ if (read?.file === undefined)
49
+ return;
50
+ for (const grant of expiredGrants(read.file, seconds))
51
+ sink({ event: "expire", cap: grant.cap, grant_id: grant.id });
52
+ spendOneShot(writer, { dir, sessionId, file: read.file, active: new Set(result.grants.sources.map((s) => s.grantId)), seconds, env: ctx.env });
53
+ }
54
+ /** Remove the one-shot entries that were just composed in (§2.3). */
55
+ function spendOneShot(writer, ctx) {
56
+ const spent = ctx.file.grants.filter((entry) => entry.uses_left === 1 && ctx.active.has(entry.id) && !isExpired(entry, ctx.seconds));
57
+ if (ctx.dir === null || ctx.sessionId === null || spent.length === 0)
58
+ return;
59
+ const keep = ctx.file.grants.filter((entry) => !spent.some((s) => s.id === entry.id));
60
+ try {
61
+ writeGrantsFile(ctx.dir, { version: 1, session: ctx.sessionId, updated_at: ctx.seconds, grants: keep }, { env: ctx.env, now: ctx.seconds });
62
+ }
63
+ catch {
64
+ return; // best-effort: the entry stays and stays valid for one more turn
65
+ }
66
+ for (const entry of spent) {
67
+ writer.write({ session: ctx.sessionId, event: "use", cap: entry.cap, grant_id: entry.id, uses_left: 0, detail: "one-shot grant spent" });
68
+ }
69
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The two lifecycle operations of ONE session generation — `clear` and `compact`
3
+ * (extracted from `real-runtime-adapter.ts`, W787: the adapter is the HTTP seam
4
+ * and stays inside the §4.1 file budget; the operations themselves are about the
5
+ * session runtime, not about the seam).
6
+ *
7
+ * - `clearSession` empties the log and resets the turn counter of the LIVE
8
+ * instance, and 409s (`TurnBusyError`) while a turn is in flight — clearing
9
+ * a session mid-turn would delete the rows the turn is writing;
10
+ * - `compactSession` compacts `<dir>/cli-main.jsonl`, and when an instance is
11
+ * live it is EVICTED first and composed again afterwards: the compaction
12
+ * rewrites the file behind the log's descriptor, so reusing the old instance
13
+ * would keep serving the pre-compaction history from memory. W825 P0: when
14
+ * `evict` REFUSES (a pinned instance with live worker work, or a busy one)
15
+ * the log is NOT rewritten at all — no orphaned descriptor, no false
16
+ * `rebound` — and `rebound:true` is reported only for a verified rebuild.
17
+ */
18
+ import { type SessionRuntimeRegistry, type Summarizer } from "@celestea/runtime";
19
+ import { type ClearOutcome, type CompactOutcome } from "../runtime-adapter.js";
20
+ import { type SessionTarget } from "./engine-session.js";
21
+ /** The frozen "nothing to compact" note (kept in sync with compact/plan.ts). */
22
+ export declare const SKIPPED_NOTE = "\u5386\u53F2\u4E0D\u8DB3\uFF0C\u65E0\u9700\u538B\u7F29";
23
+ /**
24
+ * W825 P0: the session holds LIVE worker work, so its instance is pinned and
25
+ * [SessionRuntimeRegistry.evict] refuses it. Rewriting the log anyway would
26
+ * leave the live log descriptor pointing at the unlinked old inode — every
27
+ * later append is lost on process exit while the response still claims
28
+ * `rebound:true`. The lifecycle therefore refuses (no rewrite, `rebound:false`);
29
+ * the HTTP layer turns the same condition into a 409.
30
+ */
31
+ export declare const PINNED_NOTE = "worker \u8FD0\u884C\u4E2D\uFF0C\u5DF2\u8DF3\u8FC7\u538B\u7F29";
32
+ export interface SessionLifecycleDeps {
33
+ registry: SessionRuntimeRegistry;
34
+ /** Host lookup (`<workspace>/<session>` -> dir); null for an unresolvable id. */
35
+ resolve: (id: string) => SessionTarget | null;
36
+ /** The compact summarizer of the current base profile. */
37
+ summarizer: () => Summarizer;
38
+ }
39
+ export declare function clearSession(registry: SessionRuntimeRegistry, session: string | null): ClearOutcome;
40
+ export declare function compactSession(deps: SessionLifecycleDeps, session: string): Promise<CompactOutcome>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The two lifecycle operations of ONE session generation — `clear` and `compact`
3
+ * (extracted from `real-runtime-adapter.ts`, W787: the adapter is the HTTP seam
4
+ * and stays inside the §4.1 file budget; the operations themselves are about the
5
+ * session runtime, not about the seam).
6
+ *
7
+ * - `clearSession` empties the log and resets the turn counter of the LIVE
8
+ * instance, and 409s (`TurnBusyError`) while a turn is in flight — clearing
9
+ * a session mid-turn would delete the rows the turn is writing;
10
+ * - `compactSession` compacts `<dir>/cli-main.jsonl`, and when an instance is
11
+ * live it is EVICTED first and composed again afterwards: the compaction
12
+ * rewrites the file behind the log's descriptor, so reusing the old instance
13
+ * would keep serving the pre-compaction history from memory. W825 P0: when
14
+ * `evict` REFUSES (a pinned instance with live worker work, or a busy one)
15
+ * the log is NOT rewritten at all — no orphaned descriptor, no false
16
+ * `rebound` — and `rebound:true` is reported only for a verified rebuild.
17
+ */
18
+ import { join } from "node:path";
19
+ import { keyOfSession, runCompaction } from "@celestea/runtime";
20
+ import { EngineError } from "../runtime-adapter.js";
21
+ import { TurnBusyError } from "@celestea/runtime";
22
+ import { SESSION_LOG_NAME } from "./engine-session.js";
23
+ /** The frozen "nothing to compact" note (kept in sync with compact/plan.ts). */
24
+ export const SKIPPED_NOTE = "历史不足,无需压缩";
25
+ /**
26
+ * W825 P0: the session holds LIVE worker work, so its instance is pinned and
27
+ * [SessionRuntimeRegistry.evict] refuses it. Rewriting the log anyway would
28
+ * leave the live log descriptor pointing at the unlinked old inode — every
29
+ * later append is lost on process exit while the response still claims
30
+ * `rebound:true`. The lifecycle therefore refuses (no rewrite, `rebound:false`);
31
+ * the HTTP layer turns the same condition into a 409.
32
+ */
33
+ export const PINNED_NOTE = "worker 运行中,已跳过压缩";
34
+ export function clearSession(registry, session) {
35
+ const entry = registry.peek(session);
36
+ if (entry !== null) {
37
+ if (entry.inFlight)
38
+ throw new TurnBusyError("clear");
39
+ entry.runtime.session.clear();
40
+ entry.turnNo = 0;
41
+ }
42
+ return { cleared: true };
43
+ }
44
+ export async function compactSession(deps, session) {
45
+ const target = deps.resolve(session);
46
+ if (target === null || target.dir === null) {
47
+ return { compacted: false, note: SKIPPED_NOTE, session, rebound: false };
48
+ }
49
+ const before = deps.registry.peek(session);
50
+ if (before !== null) {
51
+ // W825 P0: evict() is the ONE gate that knows both rules — a BUSY turn and a
52
+ // PINNED instance (live worker work) both refuse it. Its verdict used to be
53
+ // discarded, so a pinned session was compacted UNDER a live descriptor: the
54
+ // atomic rename replaced the path, the old fd kept pointing at the unlinked
55
+ // inode, and every later turn event was written where nobody would read it.
56
+ const evicted = await deps.registry.evict(keyOfSession(session));
57
+ if (!evicted) {
58
+ return { compacted: false, note: PINNED_NOTE, session, rebound: false };
59
+ }
60
+ }
61
+ const result = await runCompactionOf(deps, join(target.dir, SESSION_LOG_NAME));
62
+ let rebound = false;
63
+ if (before !== null) {
64
+ const after = deps.registry.ensure(session, target.dir);
65
+ // rebound is a FACT, not a promise: only a genuinely NEW runtime counts. A
66
+ // refused evict returns above, so this can never claim a rebuild that did
67
+ // not happen.
68
+ rebound = result.compacted && after.runtime !== before.runtime;
69
+ }
70
+ return {
71
+ compacted: result.compacted,
72
+ ...(result.compacted && result.kept_turns !== null ? { kept_turns: result.kept_turns } : {}),
73
+ note: result.note,
74
+ session,
75
+ rebound,
76
+ };
77
+ }
78
+ /** A compaction failure is an ENGINE error (HTTP 500), never a host-side crash. */
79
+ async function runCompactionOf(deps, logPath) {
80
+ try {
81
+ return await runCompaction({ logPath, summarize: deps.summarizer() });
82
+ }
83
+ catch (e) {
84
+ throw new EngineError(e instanceof Error ? e.message : String(e));
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The SSE publishers the host raises on behalf of ONE session (W515 §2/§4).
3
+ *
4
+ * Two concerns that look alike and are kept together for that reason:
5
+ * - an injected message's placement change (`status` frames: `queued` /
6
+ * `steering` on acceptance, `context` when a boundary consumes it);
7
+ * - a parked user question (`question`, W783).
8
+ *
9
+ * Both are "the adapter knows the session id and the bus; nobody else does", so
10
+ * both are built from the same two callbacks and neither needs the engine.
11
+ *
12
+ * NOTE (pure move, W783): the injection publisher below is W515 behaviour that
13
+ * used to live inline in `real-runtime-adapter.ts`. Nothing about what it emits
14
+ * changed — only where it is written down.
15
+ */
16
+ import { type SessionInbox } from "@celestea/runtime";
17
+ import type { PendingInjection } from "@celestea/core";
18
+ /**
19
+ * The session hooks the composer consumes (inbox + observed boundaries).
20
+ * Structurally identical to `session-compose.ts`'s own `SessionInjectionHooks`;
21
+ * the `onInjected` parameter is the composer's tamer `PendingInjection` view,
22
+ * because the composer reports a drained message before it is materialized.
23
+ */
24
+ export interface SessionInjectionHooks {
25
+ inbox: SessionInbox;
26
+ onInjected: (messages: readonly PendingInjection[], boundary: "turn-start" | "step") => void;
27
+ }
28
+ /** Reach the bus and the clock without owning either. */
29
+ export interface PublisherDeps {
30
+ /** Emit one status frame for this session. */
31
+ emitStatus: (sessionId: string | null, payload: Record<string, unknown>) => void;
32
+ now: () => number;
33
+ }
34
+ /**
35
+ * The injection hooks of one session: the ACCEPT side is observed when the inbox
36
+ * queues a message, and the CONSUME side only through `onInjected`, which knows
37
+ * WHICH boundary drained it (a mailbox receipt never enters the inbox, so it
38
+ * cannot be reported here).
39
+ */
40
+ export declare function injectionHooksOf(sessionId: string | null, deps: PublisherDeps): SessionInjectionHooks;