@dyx9/rosetta 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.
Files changed (153) hide show
  1. package/.env.example +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -2
  4. package/bin/rosetta.js +10 -0
  5. package/build/dist/assets/abnfDiagram-WEYOIA3C-CwZlxHkI.js +1 -0
  6. package/build/dist/assets/arc-B0x0q6O1.js +1 -0
  7. package/build/dist/assets/architecture-WOLXFQ4H-CkOpgBe5.js +1 -0
  8. package/build/dist/assets/architectureDiagram-ZHVYKT53-DT2OTBIB.js +36 -0
  9. package/build/dist/assets/array-BifhSqXX.js +1 -0
  10. package/build/dist/assets/bash-CLZ0U2zV.js +1 -0
  11. package/build/dist/assets/blockDiagram-RJAE7QA3-DXKyW4Qj.js +140 -0
  12. package/build/dist/assets/c4Diagram-XDPPPE54-CqHMZj3I.js +38 -0
  13. package/build/dist/assets/channel-BmjZSxDJ.js +1 -0
  14. package/build/dist/assets/chunk-2E7WHVMD-DTKlOCW9.js +88 -0
  15. package/build/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  16. package/build/dist/assets/chunk-4EMNKR2T-BsnKxZVc.js +1 -0
  17. package/build/dist/assets/chunk-55XCU76K-Ddul4yK8.js +1 -0
  18. package/build/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  19. package/build/dist/assets/chunk-7S6XSTFO-D73i4Kfs.js +2 -0
  20. package/build/dist/assets/chunk-AQDTSKZB-DJMpZsbr.js +72 -0
  21. package/build/dist/assets/chunk-DSNLSHJZ-KWPl82ns.js +129 -0
  22. package/build/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  23. package/build/dist/assets/chunk-HAGFOTED-Co5xWe5K.js +62 -0
  24. package/build/dist/assets/chunk-HNE4Q36P-D9ufLjJL.js +1 -0
  25. package/build/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  26. package/build/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  27. package/build/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  28. package/build/dist/assets/chunk-NQOKRVIF-DuVeWA2D.js +213 -0
  29. package/build/dist/assets/chunk-UHW7PFBP-DrzHcxiz.js +1 -0
  30. package/build/dist/assets/chunk-VQ5W62QI-tyK9wvZy.js +1 -0
  31. package/build/dist/assets/chunk-WQQL4CDK-BtfOu2ju.js +10 -0
  32. package/build/dist/assets/chunk-XXDRQBXY-BFig8xVb.js +1 -0
  33. package/build/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  34. package/build/dist/assets/chunk-YZO67HFG-Cw5eVjq1.js +1 -0
  35. package/build/dist/assets/chunk-ZAUX2LTX-CIpKyMEe.js +1 -0
  36. package/build/dist/assets/chunk-ZCHMQSOR-Djve8pwS.js +2 -0
  37. package/build/dist/assets/classDiagram-v2-4MYWR3UA-D4XAcUCd.js +217 -0
  38. package/build/dist/assets/cose-bilkent-JH36ORCC-DHRGyJqU.js +1 -0
  39. package/build/dist/assets/cynefin-EF2NZ3EQ-Cv-uxuqE.js +1 -0
  40. package/build/dist/assets/cynefinDiagram-WF24UDRP-MFe-UzcY.js +62 -0
  41. package/build/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  42. package/build/dist/assets/dagre-NK4GIZVX-BsY0-Q-N.js +4 -0
  43. package/build/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  44. package/build/dist/assets/diagram-CBK3MWTC-9zfXZ0bX.js +24 -0
  45. package/build/dist/assets/diagram-I5XIIV2D-CfWjvEof.js +24 -0
  46. package/build/dist/assets/diagram-ODIL2UCW-BpJaYO2i.js +30 -0
  47. package/build/dist/assets/diagram-PFBEJUXE-CtpxT_FQ.js +3 -0
  48. package/build/dist/assets/diagram-RJA3I4VV-DuLQrOcT.js +41 -0
  49. package/build/dist/assets/diagram-TYUNFSNR-w6Mohdl5.js +200 -0
  50. package/build/dist/assets/dist-DI7WHQIh.js +1 -0
  51. package/build/dist/assets/ebnfDiagram-V4P3TTPT-DlwHfKyL.js +1 -0
  52. package/build/dist/assets/elk-QONTBCTQ-DEvtD9nx.js +27 -0
  53. package/build/dist/assets/erDiagram-KYQIGQAA-DQuhkec3.js +99 -0
  54. package/build/dist/assets/eventmodeling-K75KTNOO-BO57PAOs.js +1 -0
  55. package/build/dist/assets/flowDiagram-IUGLLWBU-B_Ib7Avh.js +1 -0
  56. package/build/dist/assets/ganttDiagram-R7GPNTJY-CDGbtnUY.js +292 -0
  57. package/build/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  58. package/build/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  59. package/build/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  60. package/build/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  61. package/build/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  62. package/build/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  63. package/build/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  64. package/build/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  65. package/build/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  66. package/build/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  67. package/build/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  68. package/build/dist/assets/gitGraph-VSP46ZUC-D9ZZKP8z.js +1 -0
  69. package/build/dist/assets/gitGraphDiagram-PDYL7XHY-Da8Oypep.js +106 -0
  70. package/build/dist/assets/github-dark-C-LZuMrd.js +1 -0
  71. package/build/dist/assets/github-light-EUqPIrTm.js +1 -0
  72. package/build/dist/assets/graphlib-DS17s2tU.js +1 -0
  73. package/build/dist/assets/index-Cf0aBmwz.css +2 -0
  74. package/build/dist/assets/index-Ct3brajV.js +471 -0
  75. package/build/dist/assets/info-OHQRW6UA-BF836x6L.js +1 -0
  76. package/build/dist/assets/infoDiagram-MVDRKAX5-DpOoH-IR.js +2 -0
  77. package/build/dist/assets/init-C-OQMol4.js +1 -0
  78. package/build/dist/assets/ishikawaDiagram-HPEHV2GE-DT-Ft7vW.js +70 -0
  79. package/build/dist/assets/javascript-BgS3c2Ky.js +1 -0
  80. package/build/dist/assets/journeyDiagram-BOSHW7O7-BAF7tJ1H.js +139 -0
  81. package/build/dist/assets/json-qhed-kSA.js +1 -0
  82. package/build/dist/assets/kanban-definition-FNSA4HC5-B_w2r2eo.js +89 -0
  83. package/build/dist/assets/katex-B_JcIwqj.js +257 -0
  84. package/build/dist/assets/line-DMjgRylK.js +1 -0
  85. package/build/dist/assets/linear-BEdVl1PM.js +1 -0
  86. package/build/dist/assets/mermaid-parser.core-BDmgiU7m.js +7 -0
  87. package/build/dist/assets/mermaid.core-8Kj_dqOs.js +44 -0
  88. package/build/dist/assets/mindmap-definition-6OY57UG3-DOcaArfN.js +98 -0
  89. package/build/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  90. package/build/dist/assets/packet-JDAUHWVQ-zWLPVpvX.js +1 -0
  91. package/build/dist/assets/path-fybaL0A-.js +1 -0
  92. package/build/dist/assets/pegDiagram-ECIUJNBX-sYwsFyAl.js +1 -0
  93. package/build/dist/assets/pie-XZMESJXO-B-h0LV9S.js +1 -0
  94. package/build/dist/assets/pieDiagram-GNMPY74T-FToCIvky.js +39 -0
  95. package/build/dist/assets/python-gzcpVVnB.js +1 -0
  96. package/build/dist/assets/quadrantDiagram-D57K4MC3-tiXtbFLP.js +7 -0
  97. package/build/dist/assets/radar-ABXABTNO--ew_ZFJl.js +1 -0
  98. package/build/dist/assets/railroad-I3PHUGI6-cxsf_ZsE.js +1 -0
  99. package/build/dist/assets/railroad-abnf-LNEFI6M7-BIK-9C0Z.js +1 -0
  100. package/build/dist/assets/railroad-ebnf-DWYD2UWJ-BHw1M8VQ.js +1 -0
  101. package/build/dist/assets/railroad-peg-JR7BVNR7-MHCdZHAZ.js +1 -0
  102. package/build/dist/assets/railroadDiagram-Y72NRBL7-wjsZpnJA.js +1 -0
  103. package/build/dist/assets/requirementDiagram-EQYSW5RA-CHkzAP3w.js +84 -0
  104. package/build/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  105. package/build/dist/assets/sankeyDiagram-SY24JHO5-BBLq6ndO.js +40 -0
  106. package/build/dist/assets/sequenceDiagram-BLEHNHQU-DW_h9VZQ.js +169 -0
  107. package/build/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  108. package/build/dist/assets/src-MbiMZlpk.js +1 -0
  109. package/build/dist/assets/stateDiagram-v2-A6IESKWZ-BgpDkVUs.js +291 -0
  110. package/build/dist/assets/swimlanes-B3ENRKFQ-DJHWFYmN.js +1 -0
  111. package/build/dist/assets/swimlanesDiagram-AYQRV4AK-Cq_MGOxD.js +8 -0
  112. package/build/dist/assets/timeline-definition-JXYKGLGB-DQ-XYDnd.js +120 -0
  113. package/build/dist/assets/treeView-D4JQ5SDB-ydIf6KAy.js +1 -0
  114. package/build/dist/assets/treemap-SAJKECNS-CB6tZuFJ.js +1 -0
  115. package/build/dist/assets/typescript-bsJCZSQ-.js +1 -0
  116. package/build/dist/assets/usecaseDiagram-WPOLBNNX-D_A5G5wv.js +328 -0
  117. package/build/dist/assets/vennDiagram-Q3Y3IDCW-j2yUit_4.js +34 -0
  118. package/build/dist/assets/wardley-7MLQ67FV-BztBXEva.js +1 -0
  119. package/build/dist/assets/wardleyDiagram-IVAIDKWA-CyrXADWS.js +78 -0
  120. package/build/dist/assets/xychartDiagram-4URNHDU4-BM2yVuEH.js +7 -0
  121. package/build/dist/favicon.svg +1 -0
  122. package/build/dist/icons.svg +24 -0
  123. package/build/dist/index.html +14 -0
  124. package/build/server/agent/conversation.js +244 -0
  125. package/build/server/agent/harness.js +435 -0
  126. package/build/server/agent/sessions.js +222 -0
  127. package/build/server/agent/turn.js +56 -0
  128. package/build/server/config.js +92 -0
  129. package/build/server/http/auth.js +31 -0
  130. package/build/server/http/chat.js +67 -0
  131. package/build/server/http/feedback.js +30 -0
  132. package/build/server/http/routes.js +231 -0
  133. package/build/server/http/stream.js +39 -0
  134. package/build/server/http/util.js +40 -0
  135. package/build/server/index.js +112 -0
  136. package/build/server/records/operations.js +53 -0
  137. package/build/server/records/recording.js +186 -0
  138. package/build/server/records/turn-recording.js +151 -0
  139. package/build/server/stone/file-boundary.js +57 -0
  140. package/build/server/stone/prompt.js +83 -0
  141. package/build/server/stone/stone.js +245 -0
  142. package/build/server/stone/verify.js +141 -0
  143. package/build/src/shared/protocol.js +25 -0
  144. package/build/stones/example/AGENTS.md +25 -0
  145. package/build/stones/example/checks/hotspot.py +74 -0
  146. package/build/stones/example/checks/syntax.py +13 -0
  147. package/build/stones/example/constraints/rig-limits.md +15 -0
  148. package/build/stones/example/docs/reference.md +26 -0
  149. package/build/stones/example/examples/good/crossfade.py +15 -0
  150. package/build/stones/example/examples/violations/hotspot.py +17 -0
  151. package/build/stones/example/rosetta.json +23 -0
  152. package/build/stones/example/skills/writing-cues/SKILL.md +21 -0
  153. package/package.json +74 -4
@@ -0,0 +1,222 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Turn } from "./turn.js";
3
+ import { Recordings } from "../records/recording.js";
4
+ /** One conversation: an agent, its recording, and at most one running turn. */
5
+ export class Session {
6
+ id = randomBytes(18).toString("hex");
7
+ /** Process-local label for admin tracking; not a session credential. */
8
+ number;
9
+ agent;
10
+ recording;
11
+ createdAt = Date.now();
12
+ /** Any contact, heartbeats included. Shown to admin; it does not delay expiry. */
13
+ lastSeen = this.createdAt;
14
+ /** Session creation or last finished turn; heartbeats must not update this. */
15
+ lastConversationActivity = this.createdAt;
16
+ /** When the last question was admitted. Shown to admin. */
17
+ lastUserActivity = null;
18
+ turns = 0;
19
+ contextPercent = null;
20
+ /**
21
+ * Every admitted turn's recording, by turn ID. It finds the answer a feedback
22
+ * request refers to and shows which IDs are used. It stays in memory; IDs are
23
+ * never written to recordings.
24
+ */
25
+ turnRecordings = new Map();
26
+ /** Set when the session has left the registry. It is disposed once no turn holds it. */
27
+ closing;
28
+ activeTurn;
29
+ #inactivityMs;
30
+ #reportError;
31
+ constructor(options) {
32
+ this.number = options.number;
33
+ this.agent = options.agent;
34
+ this.recording = options.recording;
35
+ this.#inactivityMs = options.inactivityMs;
36
+ this.#reportError = options.reportError;
37
+ }
38
+ /** One question at a time per session. */
39
+ get busy() {
40
+ return this.activeTurn !== undefined;
41
+ }
42
+ /** Time left before the session expires, or null while it is answering. */
43
+ inactivityRemainingMs(now = Date.now()) {
44
+ return this.busy
45
+ ? null
46
+ : Math.max(0, this.lastConversationActivity + this.#inactivityMs - now);
47
+ }
48
+ /** A busy session never expires: its remaining time is null, not zero. */
49
+ isExpired(now) {
50
+ return this.inactivityRemainingMs(now) === 0;
51
+ }
52
+ /** Takes the session for one turn. Undefined when it is answering or closing. */
53
+ beginTurn(id) {
54
+ if (this.busy || this.closing)
55
+ return undefined;
56
+ const turn = new Turn(id);
57
+ this.activeTurn = turn;
58
+ this.lastUserActivity = Date.now();
59
+ this.turns++;
60
+ return turn;
61
+ }
62
+ /** Releases the session for its next turn. A stale owner cannot release or alter a newer turn. */
63
+ endTurn(turn) {
64
+ if (this.activeTurn === turn) {
65
+ this.activeTurn = undefined;
66
+ this.lastSeen = Date.now();
67
+ this.lastConversationActivity = this.lastSeen;
68
+ }
69
+ // A session closed during its turn is disposed once that turn lets go of it.
70
+ if (!this.activeTurn)
71
+ this.#dispose();
72
+ turn.end();
73
+ }
74
+ /** Ends the session. A running turn is cancelled, and disposal waits for it to let go. */
75
+ close(reason) {
76
+ this.closing = reason;
77
+ if (this.activeTurn)
78
+ this.activeTurn.cancel(reason);
79
+ else
80
+ this.#dispose();
81
+ }
82
+ #dispose() {
83
+ if (!this.closing)
84
+ return;
85
+ this.recording.end(this.closing);
86
+ try {
87
+ this.agent.dispose();
88
+ }
89
+ catch (error) {
90
+ this.#reportError?.("cleanup", "Session disposal failed. See the server log.");
91
+ console.error("Session disposal failed", error);
92
+ }
93
+ }
94
+ }
95
+ export const DEFAULT_INACTIVITY_MS = 30 * 60_000;
96
+ /** What every interface tells a caller whose session is unknown, closed, or expired. */
97
+ export const SESSION_NOT_FOUND = "Session not found or expired";
98
+ /** The sessions that are open: how many there may be, and when each one ends. */
99
+ export class SessionRegistry {
100
+ #sessions = new Map();
101
+ #timer;
102
+ #harness;
103
+ #options;
104
+ #inactivityMs;
105
+ #recordings;
106
+ #pending = 0;
107
+ #closed = false;
108
+ #totalSessions = 0;
109
+ get summary() {
110
+ const now = Date.now();
111
+ const current = [...this.#sessions.values()];
112
+ return {
113
+ totalSessions: this.#totalSessions,
114
+ pendingSessions: this.#pending,
115
+ busySessions: current.filter((session) => session.busy).length,
116
+ currentSessions: current.map((session) => {
117
+ const remainingMs = session.inactivityRemainingMs(now);
118
+ return {
119
+ number: session.number,
120
+ lastContact: new Date(session.lastSeen).toISOString(),
121
+ expiresInSeconds: remainingMs === null ? null : Math.ceil(remainingMs / 1000),
122
+ startedAt: new Date(session.createdAt).toISOString(),
123
+ lastUserActivity: session.lastUserActivity === null
124
+ ? null
125
+ : new Date(session.lastUserActivity).toISOString(),
126
+ turns: session.turns,
127
+ busy: session.busy,
128
+ contextPercent: session.contextPercent,
129
+ };
130
+ }),
131
+ };
132
+ }
133
+ constructor(harness, options, recordings) {
134
+ this.#harness = harness;
135
+ this.#recordings = recordings;
136
+ this.#options = options;
137
+ this.#inactivityMs = options.inactivityMs ?? DEFAULT_INACTIVITY_MS;
138
+ const interval = Math.min(15_000, Math.max(1_000, Math.floor(this.#inactivityMs / 2)));
139
+ this.#timer = setInterval(() => this.sweep(), interval);
140
+ this.#timer.unref();
141
+ }
142
+ get size() {
143
+ return this.#sessions.size;
144
+ }
145
+ get #atCapacity() {
146
+ return (this.#closed ||
147
+ this.#sessions.size + this.#pending >= this.#options.maxSessions);
148
+ }
149
+ async create() {
150
+ if (this.#atCapacity)
151
+ return undefined;
152
+ this.#pending++;
153
+ try {
154
+ const agent = await this.#harness.create();
155
+ if (this.#closed) {
156
+ agent.dispose();
157
+ return undefined;
158
+ }
159
+ const session = new Session({
160
+ number: this.#totalSessions + 1,
161
+ agent,
162
+ recording: this.#recordings.startSession(agent.metadata ?? {}),
163
+ inactivityMs: this.#inactivityMs,
164
+ reportError: this.#options.reportError,
165
+ });
166
+ this.#sessions.set(session.id, session);
167
+ this.#totalSessions++;
168
+ return session;
169
+ }
170
+ finally {
171
+ this.#pending--;
172
+ }
173
+ }
174
+ /**
175
+ * The open session with this ID, noting the contact. An overdue session is
176
+ * ended here instead of returned: after the host sleeps, a request can arrive
177
+ * before the sweep does. Contact does not renew a session; only a turn does.
178
+ */
179
+ find(id) {
180
+ const session = id ? this.#sessions.get(id) : undefined;
181
+ if (!session)
182
+ return undefined;
183
+ const now = Date.now();
184
+ if (session.isExpired(now)) {
185
+ this.destroy(session.id, "inactivity_expired");
186
+ return undefined;
187
+ }
188
+ session.lastSeen = now;
189
+ return session;
190
+ }
191
+ destroy(id, reason = "client_closed") {
192
+ const session = this.#sessions.get(id);
193
+ if (!session)
194
+ return false;
195
+ this.#sessions.delete(id);
196
+ session.close(reason);
197
+ return true;
198
+ }
199
+ /** Ends every session whose inactivity limit has passed. */
200
+ sweep() {
201
+ const now = Date.now();
202
+ for (const session of this.#sessions.values()) {
203
+ if (session.isExpired(now))
204
+ this.destroy(session.id, "inactivity_expired");
205
+ }
206
+ }
207
+ stop(id, turnId, reason = "user_stop") {
208
+ const session = this.#sessions.get(id);
209
+ if (!session?.activeTurn || session.activeTurn.id !== turnId)
210
+ return false;
211
+ session.activeTurn.cancel(reason);
212
+ return true;
213
+ }
214
+ async shutdown() {
215
+ this.#closed = true;
216
+ clearInterval(this.#timer);
217
+ const running = [...this.#sessions.values()].flatMap((session) => session.activeTurn ? [session.activeTurn.ended] : []);
218
+ for (const id of [...this.#sessions.keys()])
219
+ this.destroy(id, "server_shutdown");
220
+ await Promise.all(running);
221
+ }
222
+ }
@@ -0,0 +1,56 @@
1
+ export class TurnError extends Error {
2
+ outcome;
3
+ constructor(outcome, message) {
4
+ super(message);
5
+ this.outcome = outcome;
6
+ }
7
+ }
8
+ export function validTurnId(value) {
9
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{8,80}$/.test(value);
10
+ }
11
+ const STOP_MESSAGES = {
12
+ timeout: "The response took too long and was stopped. You can try again.",
13
+ user_stop: "Stopped.",
14
+ server_shutdown: "The server is restarting. This response is incomplete.",
15
+ };
16
+ /** What a stopped turn reports. Only the user's own Stop counts as stopped; the rest are incomplete. */
17
+ export const stopResult = (reason) => ({
18
+ outcome: reason === "user_stop" ? "stopped" : "incomplete",
19
+ message: STOP_MESSAGES[reason] ??
20
+ "The connection closed before the response finished.",
21
+ });
22
+ /** A session's running turn: which one it is, and the one way to stop it. */
23
+ export class Turn {
24
+ id;
25
+ /** Resolves when the turn is cancelled, and never if it completes. */
26
+ cancelled;
27
+ /** Resolves once the session has let go of this turn. Shutdown waits for it. */
28
+ ended;
29
+ #stopReason;
30
+ #resolveCancelled;
31
+ #resolveEnded;
32
+ constructor(id) {
33
+ this.id = id;
34
+ this.cancelled = new Promise((resolve) => {
35
+ this.#resolveCancelled = resolve;
36
+ });
37
+ this.ended = new Promise((resolve) => {
38
+ this.#resolveEnded = resolve;
39
+ });
40
+ }
41
+ /** Why the turn was cancelled, or undefined while it has not been. */
42
+ get stopReason() {
43
+ return this.#stopReason;
44
+ }
45
+ /** Cancels the turn. The first reason is kept; later calls change nothing. */
46
+ cancel(reason) {
47
+ if (this.#stopReason !== undefined)
48
+ return;
49
+ this.#stopReason = reason;
50
+ this.#resolveCancelled();
51
+ }
52
+ /** Called by the session when it lets go of this turn. */
53
+ end() {
54
+ this.#resolveEnded();
55
+ }
56
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Deployment settings, read once at startup from the environment (.env).
3
+ *
4
+ * Stones hold domain content; everything specific to one deployment is here.
5
+ * Access codes are read by http/auth.ts. See .env.example for every setting.
6
+ */
7
+ import { join } from "node:path";
8
+ import { DEFAULT_TURN_LIMITS } from "./agent/conversation.js";
9
+ import { DEFAULT_INACTIVITY_MS } from "./agent/sessions.js";
10
+ /** Every problem at once, so an operator can fix .env in one pass. */
11
+ export class ConfigError extends Error {
12
+ problems;
13
+ constructor(problems) {
14
+ super(problems.join("; "));
15
+ this.name = "ConfigError";
16
+ this.problems = problems;
17
+ }
18
+ }
19
+ /** Reject partial parses, NaN, zero, and values that overflow Node timers. */
20
+ export function positiveInteger(name, value, fallback, max = 2_147_483_647) {
21
+ const input = value ?? String(fallback);
22
+ const number = Number(input);
23
+ if (!/^\d+$/.test(input) ||
24
+ !Number.isSafeInteger(number) ||
25
+ number < 1 ||
26
+ number > max) {
27
+ throw new Error(`${name} must be an integer between 1 and ${max}`);
28
+ }
29
+ return number;
30
+ }
31
+ export const PRIVACY_VARIABLES = {
32
+ contact: "ROSETTA_PRIVACY_CONTACT",
33
+ controller: "ROSETTA_PRIVACY_CONTROLLER",
34
+ lawfulBasis: "ROSETTA_PRIVACY_LAWFUL_BASIS",
35
+ providerProcessing: "ROSETTA_PRIVACY_PROVIDER_PROCESSING",
36
+ additionalInformation: "ROSETTA_PRIVACY_ADDITIONAL_INFORMATION",
37
+ privacyUpdated: "ROSETTA_PRIVACY_UPDATED",
38
+ };
39
+ /**
40
+ * `root` is the repository root, for the default stone. `args` are the command's arguments:
41
+ * at most a stone directory, which wins over ROSETTA_STONE.
42
+ */
43
+ export function loadConfig(env, root, args = []) {
44
+ const problems = [];
45
+ const [stoneArgument, ...extra] = args;
46
+ if (extra.length || stoneArgument?.startsWith("-"))
47
+ problems.push(`Usage: rosetta [stone directory] (got: ${args.join(" ")})`);
48
+ const integer = (name, fallback, max) => {
49
+ try {
50
+ return positiveInteger(name, env[name], fallback, max);
51
+ }
52
+ catch (error) {
53
+ problems.push(error.message);
54
+ return fallback;
55
+ }
56
+ };
57
+ const recordingSetting = env.ROSETTA_RECORDINGS;
58
+ const { ROSETTA_TLS_CERT: certPath, ROSETTA_TLS_KEY: keyPath } = env;
59
+ // Half-configured TLS is refused rather than silently serving plain HTTP.
60
+ if (Boolean(certPath) !== Boolean(keyPath))
61
+ problems.push("ROSETTA_TLS_CERT and ROSETTA_TLS_KEY must both be set, or neither");
62
+ const privacy = Object.fromEntries(Object.entries(PRIVACY_VARIABLES).map(([field, name]) => [
63
+ field,
64
+ env[name]?.trim() || null,
65
+ ]));
66
+ if ((privacy.contact?.length ?? 0) > 500)
67
+ problems.push("ROSETTA_PRIVACY_CONTACT must be at most 500 characters");
68
+ const config = {
69
+ // This computer only, unless HOST shares it on the network.
70
+ host: env.HOST || "127.0.0.1",
71
+ port: integer("PORT", 3000, 65535),
72
+ stonePath: stoneArgument || env.ROSETTA_STONE || join(root, "stones/example"),
73
+ model: env.ROSETTA_MODEL || undefined,
74
+ maxSessions: integer("ROSETTA_MAX_SESSIONS", 12),
75
+ inactivityMs: integer("ROSETTA_INACTIVITY_MS", DEFAULT_INACTIVITY_MS),
76
+ turnLimits: {
77
+ timeoutMs: integer("ROSETTA_TURN_TIMEOUT_MS", DEFAULT_TURN_LIMITS.timeoutMs),
78
+ abortGraceMs: integer("ROSETTA_ABORT_GRACE_MS", DEFAULT_TURN_LIMITS.abortGraceMs),
79
+ },
80
+ recordings: {
81
+ directory: recordingSetting === "0" || recordingSetting === "false"
82
+ ? false
83
+ : recordingSetting || "data/sessions",
84
+ retentionDays: integer("ROSETTA_RECORDING_RETENTION_DAYS", 30),
85
+ },
86
+ tls: certPath && keyPath ? { certPath, keyPath } : undefined,
87
+ privacy,
88
+ };
89
+ if (problems.length)
90
+ throw new ConfigError(problems);
91
+ return config;
92
+ }
@@ -0,0 +1,31 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ export function createTokens(env = process.env) {
3
+ const requireToken = env.ROSETTA_REQUIRE_TOKEN ?? "true";
4
+ if (requireToken !== "true" && requireToken !== "false") {
5
+ throw new Error("ROSETTA_REQUIRE_TOKEN must be true or false");
6
+ }
7
+ return {
8
+ user: requireToken === "false"
9
+ ? null
10
+ : env.ROSETTA_TOKEN || randomBytes(18).toString("base64url"),
11
+ admin: env.ROSETTA_ADMIN_TOKEN || randomBytes(18).toString("base64url"),
12
+ };
13
+ }
14
+ function bearer(req) {
15
+ return req.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? "";
16
+ }
17
+ /** Constant-time comparison, so a wrong token cannot be discovered by timing. */
18
+ export function matches(supplied, expected) {
19
+ const a = Buffer.from(supplied);
20
+ const b = Buffer.from(expected);
21
+ return a.length === b.length && timingSafeEqual(a, b);
22
+ }
23
+ export function isUser(req, tokens) {
24
+ if (tokens.user === null)
25
+ return true;
26
+ const token = bearer(req);
27
+ return matches(token, tokens.user) || matches(token, tokens.admin);
28
+ }
29
+ export function isAdmin(req, tokens) {
30
+ return matches(bearer(req), tokens.admin);
31
+ }
@@ -0,0 +1,67 @@
1
+ import { openStream } from "./stream.js";
2
+ import { bodyOr400, json } from "./util.js";
3
+ const STATUS = {
4
+ invalid_message: 400,
5
+ invalid_turn_id: 400,
6
+ session_not_found: 404,
7
+ session_busy: 409,
8
+ turn_id_reused: 409,
9
+ };
10
+ const reject = (res, rejection) => json(res, STATUS[rejection.reason], { error: rejection.message });
11
+ /** Read and admit a chat request, then stream the accepted turn. */
12
+ export async function handleChat(req, res, sessionId, conversations) {
13
+ const request = await readChatRequest(req, res, sessionId, conversations);
14
+ if (!request)
15
+ return;
16
+ const admission = conversations.admit(request);
17
+ if (!admission.accepted)
18
+ return reject(res, admission.rejection);
19
+ await streamChatTurn(res, admission.turn);
20
+ }
21
+ /** Returns a request, or sends an error response without reserving a turn. */
22
+ async function readChatRequest(req, res, sessionId, conversations) {
23
+ // Turn ownership is not held while the body arrives; admission rechecks the session afterwards.
24
+ const ineligible = conversations.eligibility(sessionId);
25
+ if (ineligible) {
26
+ reject(res, ineligible);
27
+ return;
28
+ }
29
+ const body = await bodyOr400(req, res);
30
+ if (!body)
31
+ return;
32
+ return {
33
+ sessionId,
34
+ turnId: body.turnId,
35
+ message: body.message,
36
+ };
37
+ }
38
+ /** Streams progress and the terminal result, cancelling the turn if delivery cannot start or the client leaves. */
39
+ async function streamChatTurn(res, turn) {
40
+ const onClose = () => {
41
+ if (!res.writableEnded)
42
+ turn.cancel("disconnected");
43
+ };
44
+ res.on("close", onClose);
45
+ let stream;
46
+ let setupError;
47
+ try {
48
+ stream = openStream(res);
49
+ }
50
+ catch (error) {
51
+ setupError = error;
52
+ }
53
+ // A connection that is already closed, or cannot stream, is cancelled before agent work begins.
54
+ if (!stream || res.destroyed)
55
+ turn.cancel("disconnected");
56
+ let result;
57
+ try {
58
+ result = await turn.run((event) => stream?.send(event));
59
+ }
60
+ finally {
61
+ res.off("close", onClose);
62
+ }
63
+ if (!stream)
64
+ throw setupError;
65
+ // A terminal result never discards text already sent to the browser.
66
+ stream.close({ t: "terminal", ...result });
67
+ }
@@ -0,0 +1,30 @@
1
+ import { SESSION_NOT_FOUND } from "../agent/sessions.js";
2
+ import { validTurnId } from "../agent/turn.js";
3
+ import { bodyOr400, json } from "./util.js";
4
+ export async function handleFeedback(req, res, sessionId, sessions, recordingEnabled) {
5
+ const session = sessions.find(sessionId);
6
+ if (!session)
7
+ return json(res, 404, { error: SESSION_NOT_FOUND });
8
+ if (!recordingEnabled)
9
+ return json(res, 409, {
10
+ error: "Feedback is unavailable while recording is disabled",
11
+ });
12
+ const body = await bodyOr400(req, res);
13
+ if (!body)
14
+ return;
15
+ if (!validTurnId(body.turnId) ||
16
+ !(body.userFoundHelpful === null ||
17
+ typeof body.userFoundHelpful === "boolean")) {
18
+ return json(res, 400, {
19
+ error: "A valid turnId and boolean or null userFoundHelpful are required",
20
+ });
21
+ }
22
+ if (sessions.find(sessionId) !== session)
23
+ return json(res, 404, { error: SESSION_NOT_FOUND });
24
+ const recording = session.turnRecordings.get(body.turnId);
25
+ if (!recording)
26
+ return json(res, 404, { error: "Answer not found in this session" });
27
+ if (!recording.setFeedback(body.userFoundHelpful))
28
+ return json(res, 409, { error: "Wait until this answer has finished" });
29
+ return json(res, 200, { userFoundHelpful: body.userFoundHelpful });
30
+ }