@modelprofile.com/flexharness 10.17.1 → 10.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/errors.d.ts +4 -0
  3. package/dist_ts/errors.js +9 -1
  4. package/dist_ts/functions.toolpermissions.d.ts +24 -0
  5. package/dist_ts/functions.toolpermissions.js +38 -0
  6. package/dist_ts/index.d.ts +1 -0
  7. package/dist_ts/index.js +2 -1
  8. package/dist_ts/plugins.d.ts +1 -1
  9. package/dist_ts/plugins.js +1 -1
  10. package/dist_ts_chat_harness/classes.chatclient.d.ts +95 -0
  11. package/dist_ts_chat_harness/classes.chatclient.js +263 -0
  12. package/dist_ts_chat_harness/classes.chatrequesterror.d.ts +4 -0
  13. package/dist_ts_chat_harness/classes.chatrequesterror.js +8 -0
  14. package/dist_ts_chat_harness/classes.transcript.d.ts +1 -1
  15. package/dist_ts_chat_harness/classes.transcript.js +1 -1
  16. package/dist_ts_chat_harness/functions.intents.d.ts +1 -2
  17. package/dist_ts_chat_harness/functions.intents.js +1 -1
  18. package/dist_ts_chat_harness/functions.mapping.d.ts +1 -1
  19. package/dist_ts_chat_harness/functions.mapping.js +1 -1
  20. package/dist_ts_chat_harness/index.d.ts +3 -0
  21. package/dist_ts_chat_harness/index.js +3 -1
  22. package/dist_ts_chat_harness/interfaces.chat.d.ts +136 -0
  23. package/dist_ts_chat_harness/interfaces.chat.js +2 -0
  24. package/dist_ts_chat_harness/plugins.js +1 -1
  25. package/dist_ts_chat_harness_server/classes.chathub.d.ts +78 -0
  26. package/dist_ts_chat_harness_server/classes.chathub.js +332 -0
  27. package/dist_ts_chat_harness_server/classes.chatsubscription.d.ts +55 -0
  28. package/dist_ts_chat_harness_server/classes.chatsubscription.js +236 -0
  29. package/dist_ts_chat_harness_server/classes.sessionlists.d.ts +21 -0
  30. package/dist_ts_chat_harness_server/classes.sessionlists.js +70 -0
  31. package/dist_ts_chat_harness_server/classes.turngate.d.ts +38 -0
  32. package/dist_ts_chat_harness_server/classes.turngate.js +207 -0
  33. package/dist_ts_chat_harness_server/errors.d.ts +5 -0
  34. package/dist_ts_chat_harness_server/errors.js +9 -0
  35. package/dist_ts_chat_harness_server/functions.errorprojector.d.ts +11 -0
  36. package/dist_ts_chat_harness_server/functions.errorprojector.js +32 -0
  37. package/dist_ts_chat_harness_server/functions.validation.d.ts +18 -0
  38. package/dist_ts_chat_harness_server/functions.validation.js +88 -0
  39. package/dist_ts_chat_harness_server/index.d.ts +4 -0
  40. package/dist_ts_chat_harness_server/index.js +4 -0
  41. package/dist_ts_chat_harness_server/interfaces.d.ts +67 -0
  42. package/dist_ts_chat_harness_server/interfaces.js +2 -0
  43. package/dist_ts_chat_harness_server/plugins.d.ts +7 -0
  44. package/dist_ts_chat_harness_server/plugins.js +6 -0
  45. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.d.ts +22 -0
  46. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.js +55 -0
  47. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.d.ts +46 -0
  48. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.js +110 -0
  49. package/dist_ts_chat_harness_typedsocket/index.d.ts +3 -0
  50. package/dist_ts_chat_harness_typedsocket/index.js +3 -0
  51. package/dist_ts_chat_harness_typedsocket/interfaces.requests.d.ts +38 -0
  52. package/dist_ts_chat_harness_typedsocket/interfaces.requests.js +2 -0
  53. package/dist_ts_chat_harness_typedsocket/plugins.d.ts +9 -0
  54. package/dist_ts_chat_harness_typedsocket/plugins.js +8 -0
  55. package/dist_ts_tools/tool.filesystem.d.ts +2 -0
  56. package/dist_ts_tools/tool.filesystem.js +42 -1
  57. package/dist_ts_tools_node/classes.workspacereversion.d.ts +48 -0
  58. package/dist_ts_tools_node/classes.workspacereversion.js +202 -0
  59. package/dist_ts_tools_node/functions.fileimages.d.ts +22 -0
  60. package/dist_ts_tools_node/functions.fileimages.js +87 -0
  61. package/dist_ts_tools_node/index.d.ts +3 -1
  62. package/dist_ts_tools_node/index.js +2 -1
  63. package/dist_ts_tools_node/plugins.d.ts +1 -0
  64. package/dist_ts_tools_node/tool.context.d.ts +20 -0
  65. package/dist_ts_tools_node/tool.context.js +25 -4
  66. package/package.json +28 -4
  67. package/ts/00_commitinfo_data.ts +1 -1
  68. package/ts/errors.ts +10 -0
  69. package/ts/functions.toolpermissions.ts +60 -0
  70. package/ts/index.ts +6 -0
  71. package/ts/plugins.ts +1 -0
  72. package/ts_chat_harness/classes.chatclient.ts +343 -0
  73. package/ts_chat_harness/classes.chatrequesterror.ts +7 -0
  74. package/ts_chat_harness/classes.transcript.ts +1 -1
  75. package/ts_chat_harness/functions.intents.ts +2 -2
  76. package/ts_chat_harness/functions.mapping.ts +1 -1
  77. package/ts_chat_harness/index.ts +20 -0
  78. package/ts_chat_harness/interfaces.chat.ts +108 -0
  79. package/ts_chat_harness/readme.md +24 -0
  80. package/ts_chat_harness_server/classes.chathub.ts +400 -0
  81. package/ts_chat_harness_server/classes.chatsubscription.ts +257 -0
  82. package/ts_chat_harness_server/classes.sessionlists.ts +90 -0
  83. package/ts_chat_harness_server/classes.turngate.ts +247 -0
  84. package/ts_chat_harness_server/errors.ts +9 -0
  85. package/ts_chat_harness_server/functions.errorprojector.ts +40 -0
  86. package/ts_chat_harness_server/functions.validation.ts +104 -0
  87. package/ts_chat_harness_server/index.ts +19 -0
  88. package/ts_chat_harness_server/interfaces.ts +92 -0
  89. package/ts_chat_harness_server/plugins.ts +62 -0
  90. package/ts_chat_harness_server/readme.md +91 -0
  91. package/ts_chat_harness_typedsocket/classes.clienttransport.ts +87 -0
  92. package/ts_chat_harness_typedsocket/classes.serverbinding.ts +172 -0
  93. package/ts_chat_harness_typedsocket/index.ts +17 -0
  94. package/ts_chat_harness_typedsocket/interfaces.requests.ts +40 -0
  95. package/ts_chat_harness_typedsocket/plugins.ts +28 -0
  96. package/ts_chat_harness_typedsocket/readme.md +57 -0
  97. package/ts_tools/readme.md +10 -0
  98. package/ts_tools/tool.filesystem.ts +55 -0
  99. package/ts_tools_node/classes.workspacereversion.ts +255 -0
  100. package/ts_tools_node/functions.fileimages.ts +89 -0
  101. package/ts_tools_node/index.ts +7 -1
  102. package/ts_tools_node/plugins.ts +11 -0
  103. package/ts_tools_node/readme.md +27 -0
  104. package/ts_tools_node/tool.context.ts +49 -3
@@ -0,0 +1,400 @@
1
+ import * as plugins from './plugins.js';
2
+ import { FlexHarnessChatSessionLists } from './classes.sessionlists.js';
3
+ import { FlexHarnessChatSubscription } from './classes.chatsubscription.js';
4
+ import { FlexHarnessChatTurnGate } from './classes.turngate.js';
5
+ import { FlexHarnessChatRequestError, FlexHarnessChatTabUnavailableError } from './errors.js';
6
+ import {
7
+ BAD_REQUEST_MESSAGE,
8
+ STOPPED_MESSAGE,
9
+ isSameTab,
10
+ readRandomId,
11
+ tabKey,
12
+ readSendDetail,
13
+ readSessionId,
14
+ readTitle,
15
+ titleFromMessage,
16
+ } from './functions.validation.js';
17
+ import type {
18
+ IFlexHarnessChatCaller,
19
+ IFlexHarnessChatHubOptions,
20
+ IFlexHarnessChatLimits,
21
+ IFlexHarnessChatPeers,
22
+ IFlexHarnessChatTurn,
23
+ TFlexHarnessChatHubHarness,
24
+ } from './interfaces.js';
25
+
26
+ type TRequest<TMethod extends keyof plugins.IFlexHarnessChatMethods> = plugins.TFlexHarnessChatRequest<TMethod>;
27
+ type TResponse<TMethod extends keyof plugins.IFlexHarnessChatMethods> = plugins.TFlexHarnessChatResponse<TMethod>;
28
+
29
+ const NOT_FOUND_MESSAGE = 'This conversation does not exist.';
30
+ const READ_ONLY_MESSAGE = 'This conversation was imported and can only be read. Start a new conversation to continue.';
31
+ const DECISIONS: readonly string[] = ['once', 'always', 'reject'];
32
+
33
+ export const defaultFlexHarnessChatLimits: Readonly<IFlexHarnessChatLimits> = Object.freeze({
34
+ turnTimeoutMs: 10 * 60 * 1000,
35
+ admissionWaitMs: 30 * 1000,
36
+ toolTimeoutMs: 2 * 60 * 1000,
37
+ maxPendingDeltas: 2_000,
38
+ maxMessageChars: 100_000,
39
+ maxAttachments: 16,
40
+ maxAttachmentChars: 8 * 1024 * 1024,
41
+ maxTitleChars: 200,
42
+ sweepIntervalMs: 5 * 60 * 1000,
43
+ });
44
+
45
+ /** A conversation as a session list shows it; an imported one says so. */
46
+ export const flexHarnessChatSessionMeta = (sessionArg: plugins.IFlexSession): plugins.IHarnessSessionMeta => ({
47
+ ...plugins.flexSessionToHarnessMeta(sessionArg),
48
+ ...(sessionArg.origin?.kind === 'import' ? { statusLabel: 'Imported' } : {}),
49
+ });
50
+
51
+ /**
52
+ * Serves `dees-harness-chat` conversations of one FlexHarness to browser tabs, over any transport:
53
+ * a tab attaches, follows one conversation through numbered updates, sends messages that start or
54
+ * steer a turn, stops turns, answers permission requests, undoes and redoes turns, and renames and
55
+ * deletes conversations. A turn runs the tools the host gives it; `runClientTool()` runs one in the
56
+ * tab that started the turn.
57
+ *
58
+ * The host authenticates every request and names its caller: the tab and the scope it may reach. A
59
+ * session of another scope reads as not found. `stop()` stops the turns the hub started, its
60
+ * subscriptions and its timers; the harness stays the host's.
61
+ */
62
+ export class FlexHarnessChatHub<TContext> {
63
+ private readonly limits: Readonly<IFlexHarnessChatLimits>;
64
+ private readonly harness: TFlexHarnessChatHubHarness;
65
+ private readonly peers: IFlexHarnessChatPeers;
66
+ private readonly readContext: (valueArg: plugins.TJsonValue) => TContext;
67
+ private readonly titleOf: (sendArg: plugins.IHarnessComposerSendDetail) => string;
68
+ private readonly gate: FlexHarnessChatTurnGate<TContext>;
69
+ private readonly sessionLists: FlexHarnessChatSessionLists;
70
+ private readonly subscriptions = new Map<string, FlexHarnessChatSubscription>();
71
+ private readonly unsubscribe: () => void;
72
+ private readonly sweepTimer: ReturnType<typeof setInterval>;
73
+ /** the requests under way that may start a turn or a subscription; `stop()` waits for them */
74
+ private readonly requestsUnderWay = new Set<Promise<void>>();
75
+ /** the sequence of the last harness event the hub received */
76
+ private lastSequence: number | undefined;
77
+ private stopped = false;
78
+
79
+ constructor(optionsArg: IFlexHarnessChatHubOptions<TContext>) {
80
+ this.limits = Object.freeze({ ...defaultFlexHarnessChatLimits, ...optionsArg.limits });
81
+ this.harness = optionsArg.harness;
82
+ this.peers = optionsArg.peers;
83
+ this.readContext = optionsArg.readContext;
84
+ this.titleOf = optionsArg.titleOf ?? titleFromMessage;
85
+ this.gate = new FlexHarnessChatTurnGate<TContext>(this.harness, this.limits);
86
+ this.sessionLists = new FlexHarnessChatSessionLists(this.peers, flexHarnessChatSessionMeta);
87
+ this.unsubscribe = this.harness.subscribe((eventArg) => this.takeEvent(eventArg));
88
+ this.sweepTimer = setInterval(() => void this.forgetClosedTabs(), this.limits.sweepIntervalMs);
89
+ this.sweepTimer.unref?.();
90
+ }
91
+
92
+ // ---------------- requests ----------------
93
+
94
+ /** Attaches a tab: it follows nothing until it opens a conversation again. */
95
+ public async attach(callerArg: IFlexHarnessChatCaller): Promise<TResponse<'flexHarnessChatAttach'>> {
96
+ this.assertRunning();
97
+ this.closeSubscriptionsOf(callerArg);
98
+ this.sessionLists.attach(callerArg);
99
+ const sessions = await this.call(() => this.harness.listSessions(callerArg.scopeId));
100
+ return {
101
+ sessions: sessions
102
+ .filter((session) => !session.parentSessionId)
103
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
104
+ .map(flexHarnessChatSessionMeta),
105
+ };
106
+ }
107
+
108
+ public open(
109
+ callerArg: IFlexHarnessChatCaller,
110
+ requestArg: TRequest<'flexHarnessChatOpen'>,
111
+ ): Promise<TResponse<'flexHarnessChatOpen'>> {
112
+ return this.underWay(() => this.openUnderWay(callerArg, requestArg));
113
+ }
114
+
115
+ private async openUnderWay(
116
+ callerArg: IFlexHarnessChatCaller,
117
+ requestArg: TRequest<'flexHarnessChatOpen'>,
118
+ ): Promise<TResponse<'flexHarnessChatOpen'>> {
119
+ this.assertRunning();
120
+ const subscriptionId = readRandomId(requestArg?.subscriptionId);
121
+ const session = await this.requireSession(callerArg, requestArg.sessionId);
122
+ this.assertRunning();
123
+ if (this.subscriptions.has(subscriptionId)) throw new FlexHarnessChatRequestError(BAD_REQUEST_MESSAGE);
124
+ this.closeSubscriptionsOf(callerArg);
125
+ const subscription = new FlexHarnessChatSubscription({
126
+ id: subscriptionId,
127
+ tabId: callerArg.tabId,
128
+ scopeId: callerArg.scopeId,
129
+ sessionId: session.sessionId,
130
+ harness: this.harness,
131
+ peers: this.peers,
132
+ limits: this.limits,
133
+ onClosed: (closedArg) => this.forgetSubscription(closedArg),
134
+ });
135
+ // subscribed before the session is read: what changes meanwhile is buffered, not lost
136
+ this.subscriptions.set(subscriptionId, subscription);
137
+ try {
138
+ await subscription.load();
139
+ this.assertRunning();
140
+ } catch (error) {
141
+ this.closeSubscription(subscription);
142
+ throw this.toRequestError(error);
143
+ }
144
+ return { session: flexHarnessChatSessionMeta(session), readOnly: session.origin?.kind === 'import' };
145
+ }
146
+
147
+ public async close(
148
+ callerArg: IFlexHarnessChatCaller,
149
+ requestArg: TRequest<'flexHarnessChatClose'>,
150
+ ): Promise<TResponse<'flexHarnessChatClose'>> {
151
+ const subscription = this.subscriptions.get(requestArg?.subscriptionId);
152
+ if (subscription && isSameTab(subscription, callerArg)) this.closeSubscription(subscription);
153
+ return {};
154
+ }
155
+
156
+ public send(
157
+ callerArg: IFlexHarnessChatCaller,
158
+ requestArg: TRequest<'flexHarnessChatSend'>,
159
+ ): Promise<TResponse<'flexHarnessChatSend'>> {
160
+ // the wait for a turn that is ending counts from here
161
+ const deadline = Date.now() + this.limits.admissionWaitMs;
162
+ return this.underWay(() => this.sendUnderWay(callerArg, requestArg, deadline));
163
+ }
164
+
165
+ private async sendUnderWay(
166
+ callerArg: IFlexHarnessChatCaller,
167
+ requestArg: TRequest<'flexHarnessChatSend'>,
168
+ deadlineArg: number,
169
+ ): Promise<TResponse<'flexHarnessChatSend'>> {
170
+ this.assertRunning();
171
+ const send = readSendDetail(requestArg?.send, this.limits);
172
+ const context = this.readSendContext(requestArg.context);
173
+ let sessionId: string;
174
+ let created = false;
175
+ if (requestArg.sessionId === undefined) {
176
+ sessionId = (await this.call(() => this.harness.createSession(callerArg.scopeId, { title: this.titleOf(send) }))).sessionId;
177
+ created = true;
178
+ } else {
179
+ const session = await this.requireSession(callerArg, requestArg.sessionId);
180
+ if (session.origin?.kind === 'import') throw new FlexHarnessChatRequestError(READ_ONLY_MESSAGE);
181
+ sessionId = session.sessionId;
182
+ }
183
+ try {
184
+ // the hub may have stopped while the conversation was read or created
185
+ this.assertRunning();
186
+ return await this.gate.admit(callerArg, sessionId, send, context, deadlineArg);
187
+ } catch (error) {
188
+ // a conversation created for this message that never got it is not kept
189
+ if (created && !(error instanceof plugins.FlexHarnessClosedError)) {
190
+ await this.harness.deleteSession(callerArg.scopeId, sessionId).catch(() => undefined);
191
+ }
192
+ throw this.toRequestError(error);
193
+ }
194
+ }
195
+
196
+ public async abort(
197
+ callerArg: IFlexHarnessChatCaller,
198
+ requestArg: TRequest<'flexHarnessChatAbort'>,
199
+ ): Promise<TResponse<'flexHarnessChatAbort'>> {
200
+ const session = await this.requireSession(callerArg, requestArg?.sessionId);
201
+ await this.call(() => this.harness.abort(callerArg.scopeId, session.sessionId));
202
+ return {};
203
+ }
204
+
205
+ public async respondToPermission(
206
+ callerArg: IFlexHarnessChatCaller,
207
+ requestArg: TRequest<'flexHarnessChatPermission'>,
208
+ ): Promise<TResponse<'flexHarnessChatPermission'>> {
209
+ const session = await this.requireSession(callerArg, requestArg?.sessionId);
210
+ if (typeof requestArg.permissionId !== 'string' || !DECISIONS.includes(requestArg.decision)) {
211
+ throw new FlexHarnessChatRequestError(BAD_REQUEST_MESSAGE);
212
+ }
213
+ await this.call(() => this.harness.respondToPermission(
214
+ callerArg.scopeId,
215
+ session.sessionId,
216
+ requestArg.permissionId,
217
+ requestArg.decision,
218
+ ));
219
+ return {};
220
+ }
221
+
222
+ public async moveHistory(
223
+ callerArg: IFlexHarnessChatCaller,
224
+ requestArg: TRequest<'flexHarnessChatHistory'>,
225
+ ): Promise<TResponse<'flexHarnessChatHistory'>> {
226
+ const session = await this.requireSession(callerArg, requestArg?.sessionId);
227
+ if (requestArg.direction === 'undo') {
228
+ const result = await this.call(() => this.harness.undoSession(callerArg.scopeId, session.sessionId));
229
+ return { runId: result.revertedRunId };
230
+ }
231
+ if (requestArg.direction === 'redo') {
232
+ const result = await this.call(() => this.harness.redoSession(callerArg.scopeId, session.sessionId));
233
+ return { runId: result.restoredRunId };
234
+ }
235
+ throw new FlexHarnessChatRequestError(BAD_REQUEST_MESSAGE);
236
+ }
237
+
238
+ public async renameSession(
239
+ callerArg: IFlexHarnessChatCaller,
240
+ requestArg: TRequest<'flexHarnessChatSessionUpdate'>,
241
+ ): Promise<TResponse<'flexHarnessChatSessionUpdate'>> {
242
+ const title = readTitle(requestArg?.title, this.limits);
243
+ const session = await this.requireSession(callerArg, requestArg.sessionId);
244
+ const updated = await this.call(() => this.harness.updateSession(callerArg.scopeId, session.sessionId, { title }));
245
+ return { session: flexHarnessChatSessionMeta(updated) };
246
+ }
247
+
248
+ public async deleteSession(
249
+ callerArg: IFlexHarnessChatCaller,
250
+ requestArg: TRequest<'flexHarnessChatSessionDelete'>,
251
+ ): Promise<TResponse<'flexHarnessChatSessionDelete'>> {
252
+ const session = await this.requireSession(callerArg, requestArg?.sessionId);
253
+ await this.call(() => this.harness.deleteSession(callerArg.scopeId, session.sessionId));
254
+ return {};
255
+ }
256
+
257
+ // ---------------- turns ----------------
258
+
259
+ /** The running turn of a conversation; the host's model resolver and tool provider read it. */
260
+ public turnOf(scopeIdArg: string, sessionIdArg: string): IFlexHarnessChatTurn<TContext> | undefined {
261
+ return this.gate.turnOf(scopeIdArg, sessionIdArg);
262
+ }
263
+
264
+ /**
265
+ * Runs a tool of the turn in the tab that started it and answers with the tab's output. Fails with
266
+ * `FlexHarnessChatTabUnavailableError` when the tab is gone or does not answer in time.
267
+ */
268
+ public async runClientTool(
269
+ turnArg: IFlexHarnessChatTurn<TContext>,
270
+ requestArg: plugins.IFlexHarnessChatToolRequest,
271
+ optionsArg: { timeoutMs?: number; abortSignal?: AbortSignal } = {},
272
+ ): Promise<plugins.TJsonValue> {
273
+ const peer = await this.peers.find({ tabId: turnArg.tabId, scopeId: turnArg.scopeId });
274
+ if (!peer) throw new FlexHarnessChatTabUnavailableError();
275
+ try {
276
+ return await peer.runTool(requestArg, {
277
+ timeoutMs: optionsArg.timeoutMs ?? this.limits.toolTimeoutMs,
278
+ ...(optionsArg.abortSignal ? { abortSignal: optionsArg.abortSignal } : {}),
279
+ });
280
+ } catch (error) {
281
+ if (optionsArg.abortSignal?.aborted) throw error;
282
+ throw new FlexHarnessChatTabUnavailableError({ cause: error });
283
+ }
284
+ }
285
+
286
+ // ---------------- tabs and lifecycle ----------------
287
+
288
+ /** Forgets a tab that is gone: its conversation list and the conversation it followed. */
289
+ public forgetTab(tabArg: IFlexHarnessChatCaller): void {
290
+ this.closeSubscriptionsOf(tabArg);
291
+ this.sessionLists.forget(tabArg);
292
+ }
293
+
294
+ /** Stops the turns the hub started and waits until they ended; clears subscriptions and timers. */
295
+ public async stop(): Promise<void> {
296
+ if (this.stopped) return;
297
+ this.stopped = true;
298
+ clearInterval(this.sweepTimer);
299
+ this.unsubscribe();
300
+ for (const subscription of [...this.subscriptions.values()]) this.closeSubscription(subscription);
301
+ this.sessionLists.clear();
302
+ // requests under way refuse at their next step; the gate refuses and cancels what reaches it
303
+ await Promise.all([this.gate.stop(), ...this.requestsUnderWay]);
304
+ }
305
+
306
+ /** Runs a request that may start a turn or a subscription, so that `stop()` can wait for it. */
307
+ private underWay<T>(requestArg: () => Promise<T>): Promise<T> {
308
+ const request = requestArg();
309
+ const settled = request.then(() => undefined, () => undefined);
310
+ this.requestsUnderWay.add(settled);
311
+ void settled.then(() => this.requestsUnderWay.delete(settled));
312
+ return request;
313
+ }
314
+
315
+ private takeEvent(eventArg: plugins.TFlexHarnessEvent): void {
316
+ const preceding = this.lastSequence;
317
+ this.lastSequence = eventArg.sequence;
318
+ this.gate.takeEvent(eventArg);
319
+ for (const subscription of this.subscriptions.values()) subscription.takeEvent(eventArg, preceding);
320
+ if (eventArg.type === 'session.created' || eventArg.type === 'session.updated' || eventArg.type === 'session.deleted') {
321
+ if (!eventArg.session.parentSessionId) this.sessionLists.publish(eventArg);
322
+ }
323
+ }
324
+
325
+ /** Forgets the tabs that are gone and run no turn; runs every `sweepIntervalMs` on its own. */
326
+ private async forgetClosedTabs(): Promise<void> {
327
+ const tabs = new Map<string, IFlexHarnessChatCaller>();
328
+ for (const tab of [...this.sessionLists.tabs, ...this.subscriptions.values()]) {
329
+ tabs.set(tabKey(tab), { tabId: tab.tabId, scopeId: tab.scopeId });
330
+ }
331
+ for (const tab of tabs.values()) {
332
+ if (this.stopped) return;
333
+ if (this.gate.hasTurnOf(tab)) continue;
334
+ const peer = await this.peers.find(tab).catch(() => null);
335
+ if (!peer && !this.gate.hasTurnOf(tab)) this.forgetTab(tab);
336
+ }
337
+ }
338
+
339
+ private closeSubscriptionsOf(tabArg: IFlexHarnessChatCaller): void {
340
+ for (const subscription of [...this.subscriptions.values()]) {
341
+ if (isSameTab(subscription, tabArg)) this.closeSubscription(subscription);
342
+ }
343
+ }
344
+
345
+ private closeSubscription(subscriptionArg: FlexHarnessChatSubscription): void {
346
+ subscriptionArg.close();
347
+ this.forgetSubscription(subscriptionArg);
348
+ }
349
+
350
+ private forgetSubscription(subscriptionArg: FlexHarnessChatSubscription): void {
351
+ if (this.subscriptions.get(subscriptionArg.id) === subscriptionArg) this.subscriptions.delete(subscriptionArg.id);
352
+ }
353
+
354
+ // ---------------- checks and errors ----------------
355
+
356
+ private assertRunning(): void {
357
+ if (this.stopped) throw new FlexHarnessChatRequestError(STOPPED_MESSAGE);
358
+ }
359
+
360
+ private readSendContext(valueArg: plugins.TJsonValue | undefined): TContext {
361
+ try {
362
+ return this.readContext(valueArg ?? null);
363
+ } catch (error) {
364
+ if (error instanceof FlexHarnessChatRequestError) throw error;
365
+ throw new FlexHarnessChatRequestError(BAD_REQUEST_MESSAGE);
366
+ }
367
+ }
368
+
369
+ /** A root session of the caller's scope; another scope's session id reads as not found. */
370
+ private async requireSession(callerArg: IFlexHarnessChatCaller, sessionIdArg: unknown): Promise<plugins.IFlexSession> {
371
+ const sessionId = readSessionId(sessionIdArg, NOT_FOUND_MESSAGE);
372
+ let session: plugins.IFlexSession;
373
+ try {
374
+ session = await this.harness.getSession(callerArg.scopeId, sessionId);
375
+ } catch (error) {
376
+ if (error instanceof plugins.FlexHarnessNotFoundError || error instanceof plugins.FlexHarnessValidationError) {
377
+ throw new FlexHarnessChatRequestError(NOT_FOUND_MESSAGE);
378
+ }
379
+ throw this.toRequestError(error);
380
+ }
381
+ if (session.parentSessionId) throw new FlexHarnessChatRequestError(NOT_FOUND_MESSAGE);
382
+ return session;
383
+ }
384
+
385
+ private async call<T>(fnArg: () => Promise<T>): Promise<T> {
386
+ try {
387
+ return await fnArg();
388
+ } catch (error) {
389
+ throw this.toRequestError(error);
390
+ }
391
+ }
392
+
393
+ /** FlexHarness states its refusals for people; anything else stays an internal error. */
394
+ private toRequestError(errorArg: unknown): unknown {
395
+ if (errorArg instanceof FlexHarnessChatRequestError) return errorArg;
396
+ if (errorArg instanceof plugins.FlexHarnessClosedError) return new FlexHarnessChatRequestError(STOPPED_MESSAGE);
397
+ if (errorArg instanceof plugins.FlexHarnessError) return new FlexHarnessChatRequestError(errorArg.message);
398
+ return errorArg;
399
+ }
400
+ }
@@ -0,0 +1,257 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IFlexHarnessChatLimits, IFlexHarnessChatPeers } from './interfaces.js';
3
+
4
+ type TUpdate = plugins.IFlexHarnessChatUpdate;
5
+
6
+ /** What a subscription still has to tell its tab. */
7
+ interface IPendingUpdate {
8
+ reset: boolean;
9
+ rows: boolean;
10
+ deltas: plugins.THarnessStreamDelta[];
11
+ status: boolean;
12
+ usage: boolean;
13
+ permissions: boolean;
14
+ history: boolean;
15
+ steers: plugins.IFlexHarnessChatSteerOutcome[];
16
+ }
17
+
18
+ const emptyPending = (): IPendingUpdate => ({
19
+ reset: false,
20
+ rows: false,
21
+ deltas: [],
22
+ status: false,
23
+ usage: false,
24
+ permissions: false,
25
+ history: false,
26
+ steers: [],
27
+ });
28
+
29
+ const hasPending = (pendingArg: IPendingUpdate): boolean =>
30
+ pendingArg.reset
31
+ || pendingArg.rows
32
+ || pendingArg.deltas.length > 0
33
+ || pendingArg.status
34
+ || pendingArg.usage
35
+ || pendingArg.permissions
36
+ || pendingArg.history
37
+ || pendingArg.steers.length > 0;
38
+
39
+ /** The harness reads a subscription makes. */
40
+ export type TFlexHarnessChatSubscriptionHarness = Pick<
41
+ plugins.FlexHarness<unknown>,
42
+ 'getSessionView' | 'getSessionReversionInfo'
43
+ >;
44
+
45
+ export interface IFlexHarnessChatSubscriptionOptions {
46
+ id: string;
47
+ tabId: string;
48
+ scopeId: string;
49
+ sessionId: string;
50
+ harness: TFlexHarnessChatSubscriptionHarness;
51
+ peers: IFlexHarnessChatPeers;
52
+ limits: IFlexHarnessChatLimits;
53
+ /** called once when the subscription closes itself: its tab is gone or its session unreadable */
54
+ onClosed: (subscriptionArg: FlexHarnessChatSubscription) => void;
55
+ }
56
+
57
+ /**
58
+ * One tab following one conversation. A `FlexHarnessTranscript` takes every event of the harness
59
+ * instance; what changed reaches the tab as numbered updates, one at a time. A transcript that
60
+ * loses track, or an update the tab does not acknowledge, makes the next update a `reset`.
61
+ */
62
+ export class FlexHarnessChatSubscription {
63
+ public readonly id: string;
64
+ public readonly tabId: string;
65
+ public readonly scopeId: string;
66
+ public readonly sessionId: string;
67
+
68
+ private readonly harness: TFlexHarnessChatSubscriptionHarness;
69
+ private readonly peers: IFlexHarnessChatPeers;
70
+ private readonly limits: IFlexHarnessChatLimits;
71
+ private readonly onClosed: (subscriptionArg: FlexHarnessChatSubscription) => void;
72
+ private readonly transcript: plugins.FlexHarnessTranscript;
73
+ /** `loading` buffers events until the transcript holds the session's state */
74
+ private state: 'loading' | 'live' = 'loading';
75
+ private buffered: Array<{ event: plugins.TFlexHarnessEvent; preceding: number | undefined }> = [];
76
+ private seq = 0;
77
+ /** the rows as the tab last received them, as JSON, by id */
78
+ private sentRows = new Map<string, string>();
79
+ private pending: IPendingUpdate = emptyPending();
80
+ private sending = false;
81
+ private closed = false;
82
+
83
+ constructor(optionsArg: IFlexHarnessChatSubscriptionOptions) {
84
+ this.id = optionsArg.id;
85
+ this.tabId = optionsArg.tabId;
86
+ this.scopeId = optionsArg.scopeId;
87
+ this.sessionId = optionsArg.sessionId;
88
+ this.harness = optionsArg.harness;
89
+ this.peers = optionsArg.peers;
90
+ this.limits = optionsArg.limits;
91
+ this.onClosed = optionsArg.onClosed;
92
+ this.transcript = new plugins.FlexHarnessTranscript({ scopeId: this.scopeId, sessionId: this.sessionId });
93
+ }
94
+
95
+ /** Takes one event of the harness instance, in the order the harness emitted them. */
96
+ public takeEvent(eventArg: plugins.TFlexHarnessEvent, precedingArg: number | undefined): void {
97
+ if (this.closed) return;
98
+ if (this.state === 'loading') this.buffered.push({ event: eventArg, preceding: precedingArg });
99
+ else this.applyEvent(eventArg, precedingArg);
100
+ }
101
+
102
+ /** Reads the session into the transcript, replays what changed meanwhile and sends a reset. */
103
+ public async load(): Promise<void> {
104
+ this.state = 'loading';
105
+ this.buffered = [];
106
+ const view = await this.harness.getSessionView(this.scopeId, this.sessionId);
107
+ if (this.closed) return;
108
+ this.transcript.load({
109
+ messages: view.messages,
110
+ permissions: view.pendingPermissions,
111
+ session: view.session,
112
+ sequence: view.sequence,
113
+ });
114
+ const buffered = this.buffered;
115
+ this.buffered = [];
116
+ this.state = 'live';
117
+ this.pending = { ...emptyPending(), reset: true, steers: this.pending.steers };
118
+ for (const entry of buffered) {
119
+ if (this.state !== 'live') {
120
+ this.buffered.push(entry);
121
+ continue;
122
+ }
123
+ this.applyEvent(entry.event, entry.preceding);
124
+ }
125
+ void this.pump();
126
+ }
127
+
128
+ /** Stops following; nothing more reaches the tab. */
129
+ public close(): void {
130
+ this.closed = true;
131
+ this.buffered = [];
132
+ }
133
+
134
+ private applyEvent(eventArg: plugins.TFlexHarnessEvent, precedingArg: number | undefined): void {
135
+ const change = this.transcript.applyEvent(eventArg, { precedingSequence: precedingArg });
136
+ if (change.reload) {
137
+ void this.reload();
138
+ return;
139
+ }
140
+ const pending = this.pending;
141
+ if (eventArg.scopeId === this.scopeId && eventArg.sessionId === this.sessionId) {
142
+ if (eventArg.type === 'prompt.steer.applied') {
143
+ pending.steers.push({ steerId: eventArg.steerId, outcome: 'applied' });
144
+ } else if (eventArg.type === 'run.finished') {
145
+ for (const steerId of eventArg.unappliedSteerIds) pending.steers.push({ steerId, outcome: 'unapplied' });
146
+ pending.history = true;
147
+ }
148
+ }
149
+ if (change.messages) {
150
+ // the rows are read when the update is sent; they hold every delta up to then
151
+ pending.rows = true;
152
+ pending.deltas = [];
153
+ } else if (change.deltas.length && !pending.rows) {
154
+ pending.deltas.push(...change.deltas);
155
+ if (pending.deltas.length > this.limits.maxPendingDeltas) {
156
+ pending.rows = true;
157
+ pending.deltas = [];
158
+ }
159
+ }
160
+ if (change.status) pending.status = true;
161
+ if (change.usage) pending.usage = true;
162
+ if (change.permissions) pending.permissions = true;
163
+ if (hasPending(pending)) void this.pump();
164
+ }
165
+
166
+ /** The transcript lost track of the session: read it again; the tab gets a reset. */
167
+ private async reload(): Promise<void> {
168
+ if (this.state === 'loading' || this.closed) return;
169
+ try {
170
+ await this.load();
171
+ } catch {
172
+ // the session is gone or unreadable; the tab sees it in its list and opens another
173
+ this.closeSelf();
174
+ }
175
+ }
176
+
177
+ /** Sends what is pending, one update at a time, in seq order. */
178
+ private async pump(): Promise<void> {
179
+ if (this.sending) return;
180
+ this.sending = true;
181
+ let inFlight: TUpdate | null = null;
182
+ try {
183
+ while (!this.closed && this.state === 'live' && hasPending(this.pending)) {
184
+ const peer = await this.peers.find({ tabId: this.tabId, scopeId: this.scopeId });
185
+ if (!peer) {
186
+ this.closeSelf();
187
+ return;
188
+ }
189
+ if (this.closed || this.state !== 'live') return;
190
+ inFlight = await this.takeUpdate();
191
+ await peer.update(inFlight);
192
+ inFlight = null;
193
+ }
194
+ } catch {
195
+ // the tab may have missed an update; it starts over with a reset at the next change
196
+ this.pending = {
197
+ ...emptyPending(),
198
+ reset: true,
199
+ steers: [...(inFlight?.steers ?? []), ...this.pending.steers],
200
+ };
201
+ } finally {
202
+ this.sending = false;
203
+ }
204
+ }
205
+
206
+ private async takeUpdate(): Promise<TUpdate> {
207
+ const pending = this.pending;
208
+ this.pending = emptyPending();
209
+ const update: TUpdate = { subscriptionId: this.id, seq: ++this.seq };
210
+ const reset = pending.reset;
211
+ const view = reset || pending.rows || pending.status || pending.usage || pending.permissions
212
+ ? this.transcript.view()
213
+ : null;
214
+ if (reset) {
215
+ update.reset = true;
216
+ this.sentRows.clear();
217
+ }
218
+ if (view && (reset || pending.rows)) {
219
+ const sent = new Map<string, string>();
220
+ const upserts: plugins.IHarnessMessage[] = [];
221
+ for (const row of view.messages) {
222
+ const json = JSON.stringify(row);
223
+ if (this.sentRows.get(row.id) !== json) upserts.push(row);
224
+ sent.set(row.id, json);
225
+ }
226
+ this.sentRows = sent;
227
+ update.rows = { upserts, order: view.messages.map((row) => row.id) };
228
+ } else if (pending.deltas.length) {
229
+ update.deltas = pending.deltas;
230
+ }
231
+ if (view && (reset || pending.status)) update.status = view.status;
232
+ if (view && (reset || pending.usage)) update.usage = view.usage;
233
+ if (view && (reset || pending.permissions)) update.permissions = view.permissions;
234
+ if (reset || pending.history) {
235
+ const history = await this.readHistory();
236
+ if (history) update.history = history;
237
+ }
238
+ if (pending.steers.length) update.steers = pending.steers;
239
+ return update;
240
+ }
241
+
242
+ private async readHistory(): Promise<plugins.IFlexHarnessChatHistoryState | undefined> {
243
+ try {
244
+ const info = await this.harness.getSessionReversionInfo(this.scopeId, this.sessionId);
245
+ return { undoAvailable: info.undoAvailable, redoAvailable: info.redoAvailable };
246
+ } catch {
247
+ // the session is being deleted; its next state reaches the tab through the session list
248
+ return undefined;
249
+ }
250
+ }
251
+
252
+ private closeSelf(): void {
253
+ if (this.closed) return;
254
+ this.close();
255
+ this.onClosed(this);
256
+ }
257
+ }