@modelprofile.com/flexharness 10.0.0 → 10.1.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.stores.js +11 -119
- package/dist_ts/errors.d.ts +7 -2
- package/dist_ts/errors.js +1 -1
- package/dist_ts/utils.storerules.d.ts +28 -0
- package/dist_ts/utils.storerules.js +107 -0
- package/dist_ts_chat_harness/classes.transcript.d.ts +88 -0
- package/dist_ts_chat_harness/classes.transcript.js +268 -0
- package/dist_ts_chat_harness/functions.intents.d.ts +62 -0
- package/dist_ts_chat_harness/functions.intents.js +96 -0
- package/dist_ts_chat_harness/functions.mapping.d.ts +33 -0
- package/dist_ts_chat_harness/functions.mapping.js +220 -0
- package/dist_ts_chat_harness/index.d.ts +3 -0
- package/dist_ts_chat_harness/index.js +4 -0
- package/dist_ts_chat_harness/plugins.d.ts +1 -0
- package/dist_ts_chat_harness/plugins.js +2 -0
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +31 -0
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +312 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +110 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.js +437 -0
- package/dist_ts_stores_nosqldb/index.d.ts +2 -0
- package/dist_ts_stores_nosqldb/index.js +3 -0
- package/dist_ts_stores_nosqldb/plugins.d.ts +7 -0
- package/dist_ts_stores_nosqldb/plugins.js +10 -0
- package/package.json +24 -4
- package/readme.md +4 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.stores.ts +28 -172
- package/ts/errors.ts +8 -2
- package/ts/readme.md +4 -0
- package/ts/utils.storerules.ts +177 -0
- package/ts_chat_harness/classes.transcript.ts +365 -0
- package/ts_chat_harness/functions.intents.ts +169 -0
- package/ts_chat_harness/functions.mapping.ts +267 -0
- package/ts_chat_harness/index.ts +33 -0
- package/ts_chat_harness/plugins.ts +18 -0
- package/ts_chat_harness/readme.md +114 -0
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +465 -0
- package/ts_stores_nosqldb/classes.recordstore.ts +560 -0
- package/ts_stores_nosqldb/index.ts +9 -0
- package/ts_stores_nosqldb/plugins.ts +38 -0
- package/ts_stores_nosqldb/readme.md +53 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IFlexMessage,
|
|
4
|
+
IFlexPermissionRequest,
|
|
5
|
+
IFlexProjectStateResult,
|
|
6
|
+
IFlexSession,
|
|
7
|
+
TFlexHarnessEvent,
|
|
8
|
+
TFlexMessagePart,
|
|
9
|
+
} from '../ts/interfaces.js';
|
|
10
|
+
import {
|
|
11
|
+
flexMessageToHarnessRows,
|
|
12
|
+
flexPartRowId,
|
|
13
|
+
flexPartTimingKey,
|
|
14
|
+
flexPermissionToHarness,
|
|
15
|
+
flexProjectToHarnessScratchpad,
|
|
16
|
+
flexProjectToHarnessTodos,
|
|
17
|
+
flexSessionToHarnessStatus,
|
|
18
|
+
flexToolPartToHarness,
|
|
19
|
+
flexUsageToHarness,
|
|
20
|
+
type IFlexPartTiming,
|
|
21
|
+
} from './functions.mapping.js';
|
|
22
|
+
|
|
23
|
+
export interface IFlexHarnessTranscriptOptions {
|
|
24
|
+
scopeId: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What `load()` reads: the session's authoritative state, as the harness APIs return it. */
|
|
29
|
+
export interface IFlexHarnessTranscriptSnapshot {
|
|
30
|
+
/** `getMessages()` of the session. */
|
|
31
|
+
messages: readonly IFlexMessage[];
|
|
32
|
+
/** `listPendingPermissions()` of the session. */
|
|
33
|
+
permissions?: readonly IFlexPermissionRequest[];
|
|
34
|
+
/** `getSession()`; states whether a run is active or failed. */
|
|
35
|
+
session?: IFlexSession;
|
|
36
|
+
/** `getProjectState()`, when project management is enabled. */
|
|
37
|
+
project?: IFlexProjectStateResult;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The inputs of `dees-harness-chat`, as plain data the transcript does not share. */
|
|
41
|
+
export interface IFlexHarnessTranscriptView {
|
|
42
|
+
messages: plugins.IHarnessMessage[];
|
|
43
|
+
permissions: plugins.IHarnessPermissionRequest[];
|
|
44
|
+
/** FlexHarness asks no questions; always empty. */
|
|
45
|
+
questions: plugins.IHarnessQuestionRequest[];
|
|
46
|
+
status: plugins.IHarnessStatus;
|
|
47
|
+
usage: plugins.IHarnessUsage;
|
|
48
|
+
todos: plugins.IHarnessTodoItem[];
|
|
49
|
+
scratchpad?: plugins.IHarnessScratchpad;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where an event stands in its harness instance's event stream, as the host received it. */
|
|
53
|
+
export interface IFlexHarnessEventDelivery {
|
|
54
|
+
/**
|
|
55
|
+
* The `sequence` of the event of the same harness instance the host received just before this
|
|
56
|
+
* one, of any session; `undefined` for the first event after `load()`. The host forwards every
|
|
57
|
+
* event of the instance in order, and calls `load()` after a gap or a reconnect.
|
|
58
|
+
*/
|
|
59
|
+
precedingSequence: number | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What one event changed. Apply `deltas` with `applyDelta()` in order; when `messages` is true,
|
|
64
|
+
* assign `transcript.view().messages` instead, which already holds the deltas. `reload` means
|
|
65
|
+
* the transcript can no longer follow the session incrementally: read the session again and
|
|
66
|
+
* pass it to `load()`.
|
|
67
|
+
*/
|
|
68
|
+
export interface IFlexHarnessTranscriptChange {
|
|
69
|
+
messages: boolean;
|
|
70
|
+
deltas: plugins.THarnessStreamDelta[];
|
|
71
|
+
permissions: boolean;
|
|
72
|
+
status: boolean;
|
|
73
|
+
usage: boolean;
|
|
74
|
+
reload: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const utf8 = new TextEncoder();
|
|
78
|
+
|
|
79
|
+
/** UTF-8 bytes `delta` adds to `text`, counted the way FlexHarness counts them. */
|
|
80
|
+
function appendedUtf8Bytes(text: string, delta: string): number {
|
|
81
|
+
const trailing = text.charCodeAt(text.length - 1);
|
|
82
|
+
const leading = delta.charCodeAt(0);
|
|
83
|
+
const joinsSurrogatePair = trailing >= 0xd800 && trailing <= 0xdbff
|
|
84
|
+
&& leading >= 0xdc00 && leading <= 0xdfff;
|
|
85
|
+
return utf8.encode(delta).length - (joinsSurrogatePair ? 2 : 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sumUsage(
|
|
89
|
+
total: plugins.IHarnessUsage,
|
|
90
|
+
usage: plugins.IHarnessUsage,
|
|
91
|
+
): plugins.IHarnessUsage {
|
|
92
|
+
const sum = (key: keyof plugins.IHarnessUsage) => (total[key] ?? 0) + (usage[key] ?? 0);
|
|
93
|
+
return {
|
|
94
|
+
inputTokens: sum('inputTokens'),
|
|
95
|
+
outputTokens: sum('outputTokens'),
|
|
96
|
+
totalTokens: sum('totalTokens'),
|
|
97
|
+
cacheReadTokens: sum('cacheReadTokens'),
|
|
98
|
+
cacheWriteTokens: sum('cacheWriteTokens'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const noChange = (): IFlexHarnessTranscriptChange => ({
|
|
103
|
+
messages: false,
|
|
104
|
+
deltas: [],
|
|
105
|
+
permissions: false,
|
|
106
|
+
status: false,
|
|
107
|
+
usage: false,
|
|
108
|
+
reload: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Follows one FlexHarness session and keeps the `dees-harness-chat` inputs for it. It is a pure
|
|
113
|
+
* reducer over the session's public messages and events: it imports no runtime code, so it runs
|
|
114
|
+
* in the browser beside the components as well as on the server beside the harness.
|
|
115
|
+
*/
|
|
116
|
+
export class FlexHarnessTranscript {
|
|
117
|
+
public readonly scopeId: string;
|
|
118
|
+
public readonly sessionId: string;
|
|
119
|
+
private flexMessages: IFlexMessage[] = [];
|
|
120
|
+
private readonly timings = new Map<string, IFlexPartTiming>();
|
|
121
|
+
private readonly partBytes = new Map<string, number>();
|
|
122
|
+
private readonly permissionRows = new Map<string, plugins.IHarnessPermissionRequest>();
|
|
123
|
+
private status: plugins.IHarnessStatus = { type: 'idle' };
|
|
124
|
+
private usage: plugins.IHarnessUsage = {};
|
|
125
|
+
private todos: plugins.IHarnessTodoItem[] = [];
|
|
126
|
+
private scratchpad?: plugins.IHarnessScratchpad;
|
|
127
|
+
private runningQueueId?: string;
|
|
128
|
+
|
|
129
|
+
constructor(options: IFlexHarnessTranscriptOptions) {
|
|
130
|
+
this.scopeId = options.scopeId;
|
|
131
|
+
this.sessionId = options.sessionId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The queue entry whose run is running; a steer joins it. */
|
|
135
|
+
public get activeQueueId(): string | undefined {
|
|
136
|
+
return this.runningQueueId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Replaces everything with the session's authoritative state. */
|
|
140
|
+
public load(snapshot: IFlexHarnessTranscriptSnapshot): void {
|
|
141
|
+
this.flexMessages = [];
|
|
142
|
+
this.todos = [];
|
|
143
|
+
this.scratchpad = undefined;
|
|
144
|
+
this.runningQueueId = undefined;
|
|
145
|
+
this.timings.clear();
|
|
146
|
+
this.partBytes.clear();
|
|
147
|
+
for (const message of snapshot.messages) this.upsertMessage(message);
|
|
148
|
+
this.permissionRows.clear();
|
|
149
|
+
for (const request of snapshot.permissions ?? []) {
|
|
150
|
+
this.permissionRows.set(request.permissionId, flexPermissionToHarness(request));
|
|
151
|
+
}
|
|
152
|
+
this.status = snapshot.session ? flexSessionToHarnessStatus(snapshot.session) : { type: 'idle' };
|
|
153
|
+
this.usage = this.flexMessages.reduce<plugins.IHarnessUsage>(
|
|
154
|
+
(total, message) => message.usage ? sumUsage(total, flexUsageToHarness(message.usage)) : total,
|
|
155
|
+
{},
|
|
156
|
+
);
|
|
157
|
+
if (snapshot.project) this.applyProjectState(snapshot.project);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Takes in the session's task list and scratchpad after `getProjectState()`. */
|
|
161
|
+
public applyProjectState(project: IFlexProjectStateResult): void {
|
|
162
|
+
this.todos = flexProjectToHarnessTodos(project);
|
|
163
|
+
this.scratchpad = flexProjectToHarnessScratchpad(this.sessionId, project);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A copy of the component inputs; the transcript keeps its own objects. */
|
|
167
|
+
public view(): IFlexHarnessTranscriptView {
|
|
168
|
+
return structuredClone({
|
|
169
|
+
messages: this.flexMessages.flatMap((message, index) =>
|
|
170
|
+
flexMessageToHarnessRows(message, index, this.timings)),
|
|
171
|
+
permissions: [...this.permissionRows.values()],
|
|
172
|
+
questions: [],
|
|
173
|
+
status: this.status,
|
|
174
|
+
usage: this.usage,
|
|
175
|
+
todos: this.todos,
|
|
176
|
+
...(this.scratchpad === undefined ? {} : { scratchpad: this.scratchpad }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Takes one event of the harness instance. An event that does not directly follow
|
|
182
|
+
* `delivery.precedingSequence` means events were lost, possibly of this session, so the change
|
|
183
|
+
* asks for a reload.
|
|
184
|
+
*/
|
|
185
|
+
public applyEvent(
|
|
186
|
+
event: TFlexHarnessEvent,
|
|
187
|
+
delivery: IFlexHarnessEventDelivery,
|
|
188
|
+
): IFlexHarnessTranscriptChange {
|
|
189
|
+
const change = noChange();
|
|
190
|
+
if (
|
|
191
|
+
delivery.precedingSequence !== undefined
|
|
192
|
+
&& event.sequence !== delivery.precedingSequence + 1
|
|
193
|
+
) {
|
|
194
|
+
change.reload = true;
|
|
195
|
+
return change;
|
|
196
|
+
}
|
|
197
|
+
if (event.scopeId !== this.scopeId || event.sessionId !== this.sessionId) return change;
|
|
198
|
+
const timestamp = Date.parse(event.timestamp);
|
|
199
|
+
switch (event.type) {
|
|
200
|
+
case 'session.created':
|
|
201
|
+
case 'session.updated':
|
|
202
|
+
this.setStatus(change, flexSessionToHarnessStatus(event.session));
|
|
203
|
+
break;
|
|
204
|
+
case 'session.deleted':
|
|
205
|
+
this.load({ messages: [] });
|
|
206
|
+
change.messages = change.permissions = change.status = change.usage = true;
|
|
207
|
+
break;
|
|
208
|
+
case 'run.started':
|
|
209
|
+
this.setStatus(change, { type: 'busy' });
|
|
210
|
+
break;
|
|
211
|
+
case 'message.created':
|
|
212
|
+
case 'message.updated':
|
|
213
|
+
this.upsertMessage(event.message);
|
|
214
|
+
change.messages = true;
|
|
215
|
+
break;
|
|
216
|
+
case 'part.started':
|
|
217
|
+
this.timings.set(flexPartTimingKey(event.messageId, event.partId), { startedAt: timestamp });
|
|
218
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
219
|
+
break;
|
|
220
|
+
case 'part.updated':
|
|
221
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
222
|
+
break;
|
|
223
|
+
case 'part.completed': {
|
|
224
|
+
const key = flexPartTimingKey(event.messageId, event.partId);
|
|
225
|
+
this.timings.set(key, { ...this.timings.get(key), endedAt: timestamp });
|
|
226
|
+
this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'part.delta':
|
|
230
|
+
this.applyPartDelta(change, event);
|
|
231
|
+
break;
|
|
232
|
+
case 'permission.requested':
|
|
233
|
+
this.permissionRows.set(event.request.permissionId, flexPermissionToHarness(event.request));
|
|
234
|
+
change.permissions = true;
|
|
235
|
+
break;
|
|
236
|
+
case 'permission.resolved': {
|
|
237
|
+
const row = this.permissionRows.get(event.request.permissionId);
|
|
238
|
+
if (!row) break;
|
|
239
|
+
row.response = event.decision;
|
|
240
|
+
row.respondedAt = timestamp;
|
|
241
|
+
change.permissions = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case 'run.finished':
|
|
245
|
+
this.upsertMessage(event.message);
|
|
246
|
+
change.messages = true;
|
|
247
|
+
if (event.message.usage) {
|
|
248
|
+
this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
|
|
249
|
+
change.usage = true;
|
|
250
|
+
}
|
|
251
|
+
this.setStatus(change, event.status === 'failed'
|
|
252
|
+
? { type: 'error', ...(event.error ? { message: event.error.message } : {}) }
|
|
253
|
+
: { type: 'idle' });
|
|
254
|
+
break;
|
|
255
|
+
case 'prompt.started':
|
|
256
|
+
case 'prompt.running':
|
|
257
|
+
this.runningQueueId = event.queueId;
|
|
258
|
+
break;
|
|
259
|
+
case 'prompt.finished':
|
|
260
|
+
if (this.runningQueueId === event.queueId) this.runningQueueId = undefined;
|
|
261
|
+
break;
|
|
262
|
+
case 'session.history.changed':
|
|
263
|
+
change.reload = true;
|
|
264
|
+
break;
|
|
265
|
+
default:
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
return change;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private setStatus(change: IFlexHarnessTranscriptChange, status: plugins.IHarnessStatus): void {
|
|
272
|
+
if (status.type === this.status.type && status.message === this.status.message) return;
|
|
273
|
+
this.status = status;
|
|
274
|
+
change.status = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private upsertMessage(message: IFlexMessage): void {
|
|
278
|
+
const index = this.flexMessages.findIndex((entry) => entry.messageId === message.messageId);
|
|
279
|
+
const copy = structuredClone(message);
|
|
280
|
+
if (index === -1) this.flexMessages.push(copy);
|
|
281
|
+
else this.flexMessages[index] = copy;
|
|
282
|
+
for (const part of copy.parts) {
|
|
283
|
+
if (part.type === 'text' || part.type === 'reasoning') {
|
|
284
|
+
this.partBytes.set(flexPartTimingKey(copy.messageId, part.partId), utf8.encode(part.text).length);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private applyPartSnapshot(
|
|
290
|
+
change: IFlexHarnessTranscriptChange,
|
|
291
|
+
messageId: string,
|
|
292
|
+
partIndex: number,
|
|
293
|
+
part: TFlexMessagePart,
|
|
294
|
+
): void {
|
|
295
|
+
const message = this.flexMessages.find((entry) => entry.messageId === messageId);
|
|
296
|
+
if (!message || partIndex > message.parts.length) {
|
|
297
|
+
change.reload = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const previous = message.parts[partIndex];
|
|
301
|
+
message.parts[partIndex] = structuredClone(part);
|
|
302
|
+
const key = flexPartTimingKey(messageId, part.partId);
|
|
303
|
+
if (part.type === 'text' || part.type === 'reasoning') {
|
|
304
|
+
this.partBytes.set(key, utf8.encode(part.text).length);
|
|
305
|
+
}
|
|
306
|
+
const rowId = flexPartRowId(messageId, part.partId);
|
|
307
|
+
if (previous?.partId !== part.partId || previous.type !== part.type) {
|
|
308
|
+
change.messages = true;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (part.type === 'tool') {
|
|
312
|
+
change.deltas.push({
|
|
313
|
+
type: 'tool-update',
|
|
314
|
+
messageId: rowId,
|
|
315
|
+
tool: flexToolPartToHarness(part, this.timings.get(key)),
|
|
316
|
+
});
|
|
317
|
+
} else if (part.type === 'text' && previous.type === 'text') {
|
|
318
|
+
if (previous.text !== part.text) change.messages = true;
|
|
319
|
+
else if (message.status !== 'streaming' || partIndex !== message.parts.length - 1) {
|
|
320
|
+
change.deltas.push({ type: 'message-end', messageId: rowId });
|
|
321
|
+
}
|
|
322
|
+
} else if (part.type === 'reasoning' && previous.type === 'reasoning') {
|
|
323
|
+
if (previous.text !== part.text) change.messages = true;
|
|
324
|
+
else if (part.status !== 'running') {
|
|
325
|
+
change.deltas.push({
|
|
326
|
+
type: 'reasoning-end',
|
|
327
|
+
messageId: rowId,
|
|
328
|
+
partId: part.partId,
|
|
329
|
+
endedAt: this.timings.get(key)?.endedAt,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
change.messages = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private applyPartDelta(
|
|
338
|
+
change: IFlexHarnessTranscriptChange,
|
|
339
|
+
event: Extract<TFlexHarnessEvent, { type: 'part.delta' }>,
|
|
340
|
+
): void {
|
|
341
|
+
const message = this.flexMessages.find((entry) => entry.messageId === event.messageId);
|
|
342
|
+
const part = message?.parts[event.partIndex];
|
|
343
|
+
const key = flexPartTimingKey(event.messageId, event.partId);
|
|
344
|
+
if (
|
|
345
|
+
!part
|
|
346
|
+
|| part.partId !== event.partId
|
|
347
|
+
|| part.type !== event.partType
|
|
348
|
+
|| this.partBytes.get(key) !== event.baseTextUtf8Bytes
|
|
349
|
+
) {
|
|
350
|
+
change.reload = true;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const bytes = event.baseTextUtf8Bytes + appendedUtf8Bytes(part.text, event.delta);
|
|
354
|
+
if (bytes !== event.textUtf8Bytes) {
|
|
355
|
+
change.reload = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
part.text += event.delta;
|
|
359
|
+
this.partBytes.set(key, bytes);
|
|
360
|
+
const rowId = flexPartRowId(event.messageId, event.partId);
|
|
361
|
+
change.deltas.push(event.partType === 'text'
|
|
362
|
+
? { type: 'text', messageId: rowId, delta: event.delta }
|
|
363
|
+
: { type: 'reasoning', messageId: rowId, partId: event.partId, delta: event.delta });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
import type { FlexHarness } from '../ts/classes.flexharness.js';
|
|
3
|
+
import type {
|
|
4
|
+
IFlexPromptOptions,
|
|
5
|
+
IFlexPromptQueueAdmission,
|
|
6
|
+
TFlexPermissionDecision,
|
|
7
|
+
TFlexPromptPart,
|
|
8
|
+
} from '../ts/interfaces.js';
|
|
9
|
+
|
|
10
|
+
/** The session a chat view shows. */
|
|
11
|
+
export interface IFlexHarnessChatTarget {
|
|
12
|
+
scopeId: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One call a chat view asks the harness to make, as plain data a host can carry from the browser
|
|
18
|
+
* to the server that owns the harness.
|
|
19
|
+
*/
|
|
20
|
+
export type TFlexHarnessChatIntent =
|
|
21
|
+
| (IFlexHarnessChatTarget & {
|
|
22
|
+
type: 'enqueuePrompt';
|
|
23
|
+
prompt: TFlexPromptPart[];
|
|
24
|
+
options: IFlexPromptOptions;
|
|
25
|
+
})
|
|
26
|
+
| (IFlexHarnessChatTarget & {
|
|
27
|
+
type: 'steerPrompt';
|
|
28
|
+
queueId: string;
|
|
29
|
+
steerId: string;
|
|
30
|
+
prompt: TFlexPromptPart[];
|
|
31
|
+
options: IFlexPromptOptions;
|
|
32
|
+
})
|
|
33
|
+
| (IFlexHarnessChatTarget & {
|
|
34
|
+
type: 'respondToPermission';
|
|
35
|
+
permissionId: string;
|
|
36
|
+
decision: TFlexPermissionDecision;
|
|
37
|
+
})
|
|
38
|
+
| (IFlexHarnessChatTarget & { type: 'abort' });
|
|
39
|
+
|
|
40
|
+
export interface IFlexHarnessSendIntentOptions {
|
|
41
|
+
/** The running queue entry, `FlexHarnessTranscript.activeQueueId`; a steer joins it. */
|
|
42
|
+
activeQueueId?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The model hint the prompt carries. Defaults to the composer's `model`. A host that offers
|
|
45
|
+
* accounts or reasoning efforts encodes them in its own hint scheme here.
|
|
46
|
+
*/
|
|
47
|
+
modelHint?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function base64Utf8(text: string): string {
|
|
51
|
+
const bytes = new TextEncoder().encode(text);
|
|
52
|
+
let binary = '';
|
|
53
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
54
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
55
|
+
}
|
|
56
|
+
return btoa(binary);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachmentToPromptPart(attachment: plugins.IHarnessAttachment): TFlexPromptPart {
|
|
60
|
+
if (attachment.truncated) {
|
|
61
|
+
throw new Error(`Attachment "${attachment.name}" is truncated and cannot be sent.`);
|
|
62
|
+
}
|
|
63
|
+
const described = { mediaType: attachment.mediaType, name: attachment.name };
|
|
64
|
+
if (attachment.kind === 'text' && attachment.text !== undefined) {
|
|
65
|
+
return { type: 'file', data: base64Utf8(attachment.text), ...described };
|
|
66
|
+
}
|
|
67
|
+
if (attachment.dataBase64 === undefined) {
|
|
68
|
+
throw new Error(`Attachment "${attachment.name}" carries no payload.`);
|
|
69
|
+
}
|
|
70
|
+
return attachment.kind === 'image'
|
|
71
|
+
? { type: 'image', data: attachment.dataBase64, ...described }
|
|
72
|
+
: { type: 'file', data: attachment.dataBase64, ...described };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Maps `harness-send`. A message sent while idle, or queued behind the running turn, becomes a
|
|
77
|
+
* queued prompt. A steer joins the running turn; with no running turn it is the next turn's
|
|
78
|
+
* input, so it is queued as well. A steer keeps the prompt options for that case only: it answers
|
|
79
|
+
* on the running turn's model. A steer id comes from `crypto.randomUUID()`, which browsers offer
|
|
80
|
+
* in secure contexts only (HTTPS or localhost).
|
|
81
|
+
*/
|
|
82
|
+
export function flexHarnessSendIntent(
|
|
83
|
+
target: IFlexHarnessChatTarget,
|
|
84
|
+
detail: plugins.IHarnessComposerSendDetail,
|
|
85
|
+
options: IFlexHarnessSendIntentOptions = {},
|
|
86
|
+
): TFlexHarnessChatIntent {
|
|
87
|
+
const prompt: TFlexPromptPart[] = [
|
|
88
|
+
...(detail.text.length > 0 ? [{ type: 'text' as const, text: detail.text }] : []),
|
|
89
|
+
...detail.attachments.map(attachmentToPromptPart),
|
|
90
|
+
];
|
|
91
|
+
const modelHint = options.modelHint ?? detail.model;
|
|
92
|
+
const promptOptions: IFlexPromptOptions = modelHint === undefined ? {} : { modelHint };
|
|
93
|
+
if (detail.delivery === 'steer' && options.activeQueueId !== undefined) {
|
|
94
|
+
return {
|
|
95
|
+
...target,
|
|
96
|
+
type: 'steerPrompt',
|
|
97
|
+
queueId: options.activeQueueId,
|
|
98
|
+
steerId: globalThis.crypto.randomUUID(),
|
|
99
|
+
prompt,
|
|
100
|
+
options: promptOptions,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ...target, type: 'enqueuePrompt', prompt, options: promptOptions };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Maps `harness-permission-response`. The card reports a remembered approval as `remember`,
|
|
108
|
+
* which FlexHarness calls `always`.
|
|
109
|
+
*/
|
|
110
|
+
export function flexHarnessPermissionIntent(
|
|
111
|
+
target: IFlexHarnessChatTarget,
|
|
112
|
+
detail: plugins.IHarnessPermissionResponseDetail,
|
|
113
|
+
): TFlexHarnessChatIntent {
|
|
114
|
+
const decision: TFlexPermissionDecision = detail.response === 'reject'
|
|
115
|
+
? 'reject'
|
|
116
|
+
: detail.remember ? 'always' : 'once';
|
|
117
|
+
return { ...target, type: 'respondToPermission', permissionId: detail.requestId, decision };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Maps `harness-abort`. */
|
|
121
|
+
export function flexHarnessAbortIntent(target: IFlexHarnessChatTarget): TFlexHarnessChatIntent {
|
|
122
|
+
return { ...target, type: 'abort' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The harness calls `runFlexHarnessChatIntent` makes. */
|
|
126
|
+
export type TFlexHarnessChatHost = Pick<
|
|
127
|
+
FlexHarness<unknown>,
|
|
128
|
+
'enqueuePrompt' | 'steerPrompt' | 'respondToPermission' | 'abort'
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
const steerNotRunningCode = 'FLEX_STEER_NOT_RUNNING';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Makes the call an intent names, on the server that owns the harness, once the harness admitted
|
|
135
|
+
* it. A queued prompt returns its admission: its `completion` settles when the run ends, and the
|
|
136
|
+
* caller owns it, as with `enqueuePrompt()`. A steer whose turn ended before the harness took it
|
|
137
|
+
* is the next turn's input, so it is queued instead and returns that admission.
|
|
138
|
+
*/
|
|
139
|
+
export async function runFlexHarnessChatIntent(
|
|
140
|
+
harness: TFlexHarnessChatHost,
|
|
141
|
+
intent: TFlexHarnessChatIntent,
|
|
142
|
+
): Promise<IFlexPromptQueueAdmission | undefined> {
|
|
143
|
+
switch (intent.type) {
|
|
144
|
+
case 'enqueuePrompt':
|
|
145
|
+
return harness.enqueuePrompt(intent.scopeId, intent.sessionId, intent.prompt, intent.options);
|
|
146
|
+
case 'steerPrompt':
|
|
147
|
+
try {
|
|
148
|
+
await harness.steerPrompt(intent.scopeId, intent.sessionId, intent.queueId, {
|
|
149
|
+
steerId: intent.steerId,
|
|
150
|
+
prompt: intent.prompt,
|
|
151
|
+
});
|
|
152
|
+
return undefined;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if ((error as { code?: unknown }).code !== steerNotRunningCode) throw error;
|
|
155
|
+
return harness.enqueuePrompt(intent.scopeId, intent.sessionId, intent.prompt, intent.options);
|
|
156
|
+
}
|
|
157
|
+
case 'respondToPermission':
|
|
158
|
+
await harness.respondToPermission(
|
|
159
|
+
intent.scopeId,
|
|
160
|
+
intent.sessionId,
|
|
161
|
+
intent.permissionId,
|
|
162
|
+
intent.decision,
|
|
163
|
+
);
|
|
164
|
+
return undefined;
|
|
165
|
+
case 'abort':
|
|
166
|
+
await harness.abort(intent.scopeId, intent.sessionId);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|