dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track DSH's input queue and answer `maker:input:get-projection`.
|
|
3
|
+
*
|
|
4
|
+
* DSH publishes queue and job state as an `AsyncIterable` of control frames from
|
|
5
|
+
* `sessionController.control()` — a baseline followed by replacements — so the
|
|
6
|
+
* answer is a fold over that stream, not a query. This module keeps the latest
|
|
7
|
+
* state per session.
|
|
8
|
+
*
|
|
9
|
+
* The controller's shape is `InputProjection`
|
|
10
|
+
* (`apps/mobile/src/session/types.ts`), and its reader is defensive: an entry is
|
|
11
|
+
* kept only when it carries `clientId`, `text`, `persistedContent`, `model`,
|
|
12
|
+
* `workingDir`, `createOpts`, and `chatMessage.role === 'user'`
|
|
13
|
+
* (`apps/mobile/src/session/inputProjection.ts`). Anything less is dropped, so a
|
|
14
|
+
* partially-known queue degrades to a shorter queue rather than to a broken
|
|
15
|
+
* composer — which is why this Host emits only entries it can fill honestly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The one agent kind this Host presents; the controller's union is closed. */
|
|
19
|
+
const DSH_AGENT_KIND = 'pi';
|
|
20
|
+
const DSH_MODEL = 'pi';
|
|
21
|
+
const DSH_EFFORT = 'default';
|
|
22
|
+
const DSH_PERMISSION_MODE = 'default';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The session's working directory, from whichever row shape was handed in.
|
|
26
|
+
*
|
|
27
|
+
* The queue tracker is given a **source** row, whose field is `cwd`
|
|
28
|
+
* (`dsh-session-source.js`), while the row the controller sees on the wire
|
|
29
|
+
* calls the same value `workingDir` (`cindy-session-row.js`). Reading only one
|
|
30
|
+
* name silently emptied every projection: `toQueuedRemoteMessage` dropped DSH's
|
|
31
|
+
* real queue entries, and `queuedRowFromController` returned null, so the item a
|
|
32
|
+
* controller had just sent was missing from its own `maker:input:enqueue`
|
|
33
|
+
* answer. The composer therefore never settled — the draft stayed put and the
|
|
34
|
+
* next send repeated the same text — and the queue panel showed nothing.
|
|
35
|
+
* @param session - a session row in either shape.
|
|
36
|
+
* @returns the working directory, or null when neither name carries one.
|
|
37
|
+
*/
|
|
38
|
+
function workingDirOf(session) {
|
|
39
|
+
if (typeof session?.workingDir === 'string' && session.workingDir !== '') return session.workingDir;
|
|
40
|
+
if (typeof session?.cwd === 'string' && session.cwd !== '') return session.cwd;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Flatten a DSH queued message's content blocks into display text. */
|
|
45
|
+
function textOf(content) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const part of Array.isArray(content) ? content : []) {
|
|
48
|
+
if (part === null || typeof part !== 'object') continue;
|
|
49
|
+
if (part.type === 'text' && typeof part.text === 'string') parts.push(part.text);
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the controller's queued-message row for one DSH queue entry.
|
|
56
|
+
*
|
|
57
|
+
* Every field below is read by the controller's validator, so an entry is only
|
|
58
|
+
* emitted when the session supplies the working directory it needs.
|
|
59
|
+
* @param item - a DSH `SessionQueuedItem`.
|
|
60
|
+
* @param session - the owning session row (`{ id, title, workingDir }`).
|
|
61
|
+
* @returns the row, or null when it cannot be filled honestly.
|
|
62
|
+
*/
|
|
63
|
+
export function toQueuedRemoteMessage(item, session) {
|
|
64
|
+
const workingDir = workingDirOf(session);
|
|
65
|
+
const id = item?.id === undefined || item?.id === null ? null : String(item.id);
|
|
66
|
+
// `rpcId` is the identity the **controller** minted for this prompt, and it is
|
|
67
|
+
// the field its own submission echo retires on. Reporting DSH's `MessageId`
|
|
68
|
+
// instead made the optimistic row and the confirmed row look like two
|
|
69
|
+
// different messages: the composer kept the draft, so the next thing the user
|
|
70
|
+
// typed was appended to the unsent text, and both went out as one message.
|
|
71
|
+
const clientId = typeof item?.rpcId === 'string' && item.rpcId !== '' ? item.rpcId : id;
|
|
72
|
+
if (clientId === null || workingDir === null) return null;
|
|
73
|
+
|
|
74
|
+
const content = Array.isArray(item?.message?.content) ? item.message.content : [];
|
|
75
|
+
const text = textOf(content);
|
|
76
|
+
const createOpts = {
|
|
77
|
+
agentKind: DSH_AGENT_KIND,
|
|
78
|
+
workingDir,
|
|
79
|
+
model: DSH_MODEL,
|
|
80
|
+
effort: DSH_EFFORT,
|
|
81
|
+
permissionMode: DSH_PERMISSION_MODE,
|
|
82
|
+
fastMode: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
clientId,
|
|
87
|
+
text,
|
|
88
|
+
persistedContent: JSON.stringify(content),
|
|
89
|
+
files: [],
|
|
90
|
+
agentReferences: [],
|
|
91
|
+
model: DSH_MODEL,
|
|
92
|
+
effort: DSH_EFFORT,
|
|
93
|
+
permissionMode: DSH_PERMISSION_MODE,
|
|
94
|
+
workingDir,
|
|
95
|
+
createOpts,
|
|
96
|
+
// The controller's validator requires a user-role chat message.
|
|
97
|
+
chatMessage: { role: 'user', clientId, content },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the controller's queued row for a message this Host just accepted.
|
|
103
|
+
*
|
|
104
|
+
* `maker:input:enqueue` hands the controller's own row in and expects the new
|
|
105
|
+
* projection back with it present. DSH's queue frame arrives over the control
|
|
106
|
+
* stream asynchronously, so the freshly accepted item is folded in here: the
|
|
107
|
+
* `prompt` call already returned `accepted`, which is what makes showing it
|
|
108
|
+
* accurate rather than optimistic guesswork.
|
|
109
|
+
* @param input - the controller's clientId and text, plus the owning session.
|
|
110
|
+
* @returns the row, or null when it cannot be filled.
|
|
111
|
+
*/
|
|
112
|
+
export function queuedRowFromController({ clientId, text, session }) {
|
|
113
|
+
const workingDir = workingDirOf(session);
|
|
114
|
+
if (typeof clientId !== 'string' || clientId === '' || workingDir === null) return null;
|
|
115
|
+
const value = typeof text === 'string' ? text : '';
|
|
116
|
+
const createOpts = {
|
|
117
|
+
agentKind: DSH_AGENT_KIND,
|
|
118
|
+
workingDir,
|
|
119
|
+
model: DSH_MODEL,
|
|
120
|
+
effort: DSH_EFFORT,
|
|
121
|
+
permissionMode: DSH_PERMISSION_MODE,
|
|
122
|
+
fastMode: false,
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
clientId,
|
|
126
|
+
text: value,
|
|
127
|
+
persistedContent: JSON.stringify([{ type: 'text', text: value }]),
|
|
128
|
+
files: [],
|
|
129
|
+
agentReferences: [],
|
|
130
|
+
model: DSH_MODEL,
|
|
131
|
+
effort: DSH_EFFORT,
|
|
132
|
+
permissionMode: DSH_PERMISSION_MODE,
|
|
133
|
+
workingDir,
|
|
134
|
+
createOpts,
|
|
135
|
+
chatMessage: { role: 'user', clientId, content: [{ type: 'text', text: value }] },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fold the control stream into per-session queue state.
|
|
141
|
+
* @returns the tracker the runtime feeds and the channel reads.
|
|
142
|
+
*/
|
|
143
|
+
export function createInputQueueTracker() {
|
|
144
|
+
/** sessionId -> SessionQueuedItem[] */
|
|
145
|
+
const queues = new Map();
|
|
146
|
+
/** sessionId -> SessionJob[] */
|
|
147
|
+
const jobs = new Map();
|
|
148
|
+
/**
|
|
149
|
+
* sessionId -> controller-owned queue UI state.
|
|
150
|
+
*
|
|
151
|
+
* The controller sends `set-expanded` / `set-edit-lock` / `set-interaction-lock`
|
|
152
|
+
* as real invokes and then reads them back out of the projection, so this Host
|
|
153
|
+
* is their source of truth: they are queue state, not renderer state. They live
|
|
154
|
+
* in memory only, which is honest — a lock is held by a screen that is open now.
|
|
155
|
+
*/
|
|
156
|
+
const ui = new Map();
|
|
157
|
+
|
|
158
|
+
function uiFor(sessionId) {
|
|
159
|
+
let state = ui.get(sessionId);
|
|
160
|
+
if (state === undefined) {
|
|
161
|
+
state = { expanded: false, editLocks: new Set(), interactionLocks: new Set() };
|
|
162
|
+
ui.set(sessionId, state);
|
|
163
|
+
}
|
|
164
|
+
return state;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The queued item a **controller** is addressing.
|
|
169
|
+
*
|
|
170
|
+
* The controller knows a queued row by the `clientId` it minted (which DSH
|
|
171
|
+
* persists as the item's `rpcId`), while `updateQueue` is keyed on the durable
|
|
172
|
+
* `MessageId`. They are different values — passing the controller's id
|
|
173
|
+
* straight to the service looks up an item that does not exist, and matching
|
|
174
|
+
* only on `id` here made every removal and promotion silently miss.
|
|
175
|
+
*/
|
|
176
|
+
function itemOf(sessionId, controllerId) {
|
|
177
|
+
const wanted = String(controllerId);
|
|
178
|
+
return (queues.get(sessionId) ?? []).find((item) => {
|
|
179
|
+
const id = item?.id === undefined || item?.id === null ? null : String(item.id);
|
|
180
|
+
const rpcId = typeof item?.rpcId === 'string' && item.rpcId !== '' ? item.rpcId : id;
|
|
181
|
+
return rpcId === wanted || id === wanted;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Apply a committed queue mutation to the fold.
|
|
187
|
+
*
|
|
188
|
+
* DSH's own `queue` frame follows the commit, but the controller is answered
|
|
189
|
+
* from this fold **immediately**: answering with the pre-mutation queue would
|
|
190
|
+
* show a row the user just deleted, and the composer would put it back.
|
|
191
|
+
*/
|
|
192
|
+
function mirror(sessionId, controllerId, action) {
|
|
193
|
+
const items = queues.get(sessionId);
|
|
194
|
+
if (items === undefined) return;
|
|
195
|
+
const target = itemOf(sessionId, controllerId);
|
|
196
|
+
if (target === undefined) return;
|
|
197
|
+
const id = String(target.id);
|
|
198
|
+
if (action?.kind === 'remove') {
|
|
199
|
+
const next = items.filter((item) => String(item?.id) !== id);
|
|
200
|
+
if (next.length > 0) queues.set(sessionId, next);
|
|
201
|
+
else queues.delete(sessionId);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (action?.kind === 'steer') {
|
|
205
|
+
queues.set(sessionId, items.map((item) => (String(item?.id) === id ? { ...item, placement: 'steering' } : item)));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (action?.kind === 'edit') {
|
|
209
|
+
queues.set(sessionId, items.map((item) => (String(item?.id) === id
|
|
210
|
+
? { ...item, message: { ...item.message, content: action.content } }
|
|
211
|
+
: item)));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Upsert one prompt into the fold with a placement, preserving everything else.
|
|
217
|
+
*
|
|
218
|
+
* `mirror` can only re-place an item the fold already holds, and a *fresh* prompt is in
|
|
219
|
+
* no frame we have seen yet: DSH splices it into the inbox a moment after `prompt()`
|
|
220
|
+
* returns, so an authoritative read taken right then can legitimately come back empty —
|
|
221
|
+
* measured, that is exactly how a promoted 插话 left the projection saying nothing about
|
|
222
|
+
* the message the user was looking at ("we still lost it": 08:54:29 enqueued, 08:54:40
|
|
223
|
+
* promoted, 08:55:22 durable, with `steeringQueueClientIds` empty the whole time).
|
|
224
|
+
* Anything the controller is showing therefore has to be insertable by id.
|
|
225
|
+
*
|
|
226
|
+
* @param sessionId - the session.
|
|
227
|
+
* @param item - `{ id, rpcId?, message? }` for the prompt.
|
|
228
|
+
* @param placement - `'queued'` or `'steering'`.
|
|
229
|
+
*/
|
|
230
|
+
function markItem(sessionId, item, placement) {
|
|
231
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
232
|
+
const id = item?.id === undefined || item?.id === null ? null : String(item.id);
|
|
233
|
+
if (id === null) return;
|
|
234
|
+
const items = queues.get(sessionId) ?? [];
|
|
235
|
+
const others = items.filter((entry) => String(entry?.id) !== id);
|
|
236
|
+
queues.set(sessionId, [...others, { ...item, id, placement }]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Record a prompt this Host has just accepted as a **steering** item.
|
|
241
|
+
*
|
|
242
|
+
* Without this the projection says nothing about the message the user just sent, so the
|
|
243
|
+
* controller retires its own bubble and nothing replaces it — reported as
|
|
244
|
+
* 「在我的手机端它直接转圈转圈然后就消失了」. The entry is stored under the controller's own
|
|
245
|
+
* id and is removed by `retireQueuedItem` the moment the durable row lands.
|
|
246
|
+
*
|
|
247
|
+
* @param sessionId - the session the prompt went to.
|
|
248
|
+
* @param item - `{ id, rpcId, message }` for the accepted prompt.
|
|
249
|
+
*/
|
|
250
|
+
function markSteering(sessionId, item) {
|
|
251
|
+
markItem(sessionId, item, 'steering');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Record a prompt this Host has just accepted as **queued** (a running turn holds it). */
|
|
255
|
+
function markQueued(sessionId, item) {
|
|
256
|
+
markItem(sessionId, item, 'queued');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Reorder one queued item, as `maker:input:move` asks. */
|
|
260
|
+
function moveItem(sessionId, controllerId, targetIndex) {
|
|
261
|
+
const items = queues.get(sessionId);
|
|
262
|
+
if (items === undefined) return;
|
|
263
|
+
const target = itemOf(sessionId, controllerId);
|
|
264
|
+
if (target === undefined) return;
|
|
265
|
+
const id = String(target.id);
|
|
266
|
+
const from = items.findIndex((item) => String(item?.id) === id);
|
|
267
|
+
if (from === -1) return;
|
|
268
|
+
const next = [...items];
|
|
269
|
+
const [moved] = next.splice(from, 1);
|
|
270
|
+
const to = Math.max(0, Math.min(Number.isInteger(targetIndex) ? targetIndex : from, next.length));
|
|
271
|
+
next.splice(to, 0, moved);
|
|
272
|
+
queues.set(sessionId, next);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Rebuild one session's queue state. */
|
|
276
|
+
function setItems(sessionId, items) {
|
|
277
|
+
if (Array.isArray(items) && items.length > 0) queues.set(sessionId, items);
|
|
278
|
+
else queues.delete(sessionId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Apply one `SessionControlFrame`.
|
|
283
|
+
*
|
|
284
|
+
* `baseline` replaces everything (it is the stream's starting point);
|
|
285
|
+
* `queue`/`jobs` replace one session's slice. `projection` frames carry model
|
|
286
|
+
* projections, which this Host does not serve, and are ignored.
|
|
287
|
+
*/
|
|
288
|
+
function apply(frame) {
|
|
289
|
+
if (frame === null || typeof frame !== 'object') return;
|
|
290
|
+
if (frame.type === 'baseline') {
|
|
291
|
+
queues.clear();
|
|
292
|
+
jobs.clear();
|
|
293
|
+
for (const [sessionId, items] of Object.entries(frame.value?.queues ?? {})) {
|
|
294
|
+
if (Array.isArray(items)) queues.set(sessionId, items);
|
|
295
|
+
}
|
|
296
|
+
for (const [sessionId, entries] of Object.entries(frame.value?.jobs ?? {})) {
|
|
297
|
+
if (Array.isArray(entries)) jobs.set(sessionId, entries);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (frame.type === 'queue' && typeof frame.sessionId === 'string') {
|
|
302
|
+
setItems(frame.sessionId, frame.items);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (frame.type === 'jobs' && typeof frame.sessionId === 'string') {
|
|
306
|
+
if (Array.isArray(frame.jobs) && frame.jobs.length > 0) jobs.set(frame.sessionId, frame.jobs);
|
|
307
|
+
else jobs.delete(frame.sessionId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The queued entries DSH is holding for one session. */
|
|
312
|
+
function queueFor(sessionId) {
|
|
313
|
+
return queues.get(sessionId) ?? [];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The background jobs DSH is running for one session. */
|
|
317
|
+
function jobsFor(sessionId) {
|
|
318
|
+
return jobs.get(sessionId) ?? [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Build the controller's `InputProjection` for one session.
|
|
323
|
+
*
|
|
324
|
+
* Only the queue is projected. The error/recovery fields describe a turn that
|
|
325
|
+
* failed inside the controller's own agent; DSH reports those as session
|
|
326
|
+
* events instead, so claiming them here would be invention. They are emitted
|
|
327
|
+
* as the controller's own empty values.
|
|
328
|
+
* @param sessionId - the session to project.
|
|
329
|
+
* @param session - the owning session row, for `workingDir`.
|
|
330
|
+
* @param pending - a row to fold in as queued when DSH's own frame has not
|
|
331
|
+
* arrived yet (the just-accepted `enqueue`).
|
|
332
|
+
* @returns the projection the controller normalizes.
|
|
333
|
+
*/
|
|
334
|
+
function projectionFor(sessionId, session, pending = null, items = null) {
|
|
335
|
+
// `items` lets a caller project a queue DSH actually reported, instead of the
|
|
336
|
+
// fold — the two disagree exactly when the fold never heard a frame.
|
|
337
|
+
const source = Array.isArray(items) ? items : queueFor(sessionId);
|
|
338
|
+
const pendingQueue = [];
|
|
339
|
+
const steeringQueueClientIds = [];
|
|
340
|
+
const seen = new Set();
|
|
341
|
+
for (const item of source) {
|
|
342
|
+
const row = toQueuedRemoteMessage(item, session);
|
|
343
|
+
if (row === null) continue;
|
|
344
|
+
seen.add(row.clientId);
|
|
345
|
+
// DSH next-step (steering) is still an inbox wait, not a consumed message.
|
|
346
|
+
// Cindy supports the same id in both fields: the body survives re-entry
|
|
347
|
+
// and its phase is 'sending', with queue actions disabled until delivery.
|
|
348
|
+
if (item?.placement === 'queued' || item?.placement === 'steering') pendingQueue.push(row);
|
|
349
|
+
if (item?.placement === 'steering') steeringQueueClientIds.push(row.clientId);
|
|
350
|
+
}
|
|
351
|
+
if (pending !== null && !seen.has(pending.clientId)) pendingQueue.push(pending);
|
|
352
|
+
|
|
353
|
+
const state = uiFor(sessionId);
|
|
354
|
+
return {
|
|
355
|
+
sessionId,
|
|
356
|
+
pendingQueue,
|
|
357
|
+
steeringQueueClientIds,
|
|
358
|
+
queuePaused: false,
|
|
359
|
+
queueExpanded: state.expanded,
|
|
360
|
+
queueInteractionLocks: [...state.interactionLocks],
|
|
361
|
+
queueEditLocks: [...state.editLocks],
|
|
362
|
+
queueAbortPending: false,
|
|
363
|
+
error: null,
|
|
364
|
+
errorReason: null,
|
|
365
|
+
toolLoop: null,
|
|
366
|
+
recovery: null,
|
|
367
|
+
errorRetryText: null,
|
|
368
|
+
credentialSwitchWait: null,
|
|
369
|
+
autoResumePending: null,
|
|
370
|
+
continuationTurnClientId: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function clear() {
|
|
375
|
+
queues.clear();
|
|
376
|
+
jobs.clear();
|
|
377
|
+
ui.clear();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
apply,
|
|
382
|
+
queueFor,
|
|
383
|
+
jobsFor,
|
|
384
|
+
projectionFor,
|
|
385
|
+
clear,
|
|
386
|
+
/** Whether DSH is holding this item; decides steer-promotion vs a new send. */
|
|
387
|
+
hasItem: (sessionId, itemId) => itemOf(sessionId, itemId) !== undefined,
|
|
388
|
+
/**
|
|
389
|
+
* The durable `MessageId` behind the controller's id.
|
|
390
|
+
*
|
|
391
|
+
* `updateQueue` is keyed on this, while the controller addresses a row by the
|
|
392
|
+
* `clientId` it minted — so every mutation has to be translated or DSH looks
|
|
393
|
+
* up an item that does not exist (and answers "queued item is no longer
|
|
394
|
+
* pending", which reads like a race rather than a wrong id).
|
|
395
|
+
*/
|
|
396
|
+
dshItemId: (sessionId, controllerId) => {
|
|
397
|
+
const found = itemOf(sessionId, controllerId);
|
|
398
|
+
return found === undefined ? null : String(found.id);
|
|
399
|
+
},
|
|
400
|
+
/** Fold a queue mutation this Host already committed to DSH. */
|
|
401
|
+
mirror,
|
|
402
|
+
moveItem,
|
|
403
|
+
setExpanded: (sessionId, expanded) => { uiFor(sessionId).expanded = expanded === true; },
|
|
404
|
+
setEditLock: (sessionId, clientId, locked) => {
|
|
405
|
+
const locks = uiFor(sessionId).editLocks;
|
|
406
|
+
if (locked === true) locks.add(String(clientId));
|
|
407
|
+
else locks.delete(String(clientId));
|
|
408
|
+
},
|
|
409
|
+
setInteractionLock: (sessionId, lockId, locked) => {
|
|
410
|
+
const locks = uiFor(sessionId).interactionLocks;
|
|
411
|
+
if (locked === true) locks.add(String(lockId));
|
|
412
|
+
else locks.delete(String(lockId));
|
|
413
|
+
},
|
|
414
|
+
/** Drop every queued item for one session, as `maker:input:clear-session` asks. */
|
|
415
|
+
clearSession: (sessionId) => { setItems(sessionId, []); },
|
|
416
|
+
/**
|
|
417
|
+
* Replace one session's queue with items an authoritative read returned.
|
|
418
|
+
*
|
|
419
|
+
* This is how a durable id becomes known without waiting for a `queue` frame:
|
|
420
|
+
* the read already carries it, so a cancel or edit issued moments later
|
|
421
|
+
* resolves instead of reporting NOT_FOUND.
|
|
422
|
+
*/
|
|
423
|
+
adopt: (sessionId, items) => { setItems(sessionId, items); },
|
|
424
|
+
/** Record one just-accepted steering prompt, preserving the rest of the queue. */
|
|
425
|
+
markSteering: (sessionId, item) => { markSteering(sessionId, item); },
|
|
426
|
+
/** Record one just-accepted queued prompt, preserving the rest of the queue. */
|
|
427
|
+
markQueued: (sessionId, item) => { markQueued(sessionId, item); },
|
|
428
|
+
/**
|
|
429
|
+
* The fold's own record for one item, by the controller's id or DSH's.
|
|
430
|
+
*
|
|
431
|
+
* Needed because the pending projection renders the message **content**: an upsert
|
|
432
|
+
* carrying only an id produced an empty bubble (「插入之后里面的文字被清空了」).
|
|
433
|
+
* Callers that re-place an item preserve what the fold
|
|
434
|
+
* already knew instead of restating it from memory.
|
|
435
|
+
*/
|
|
436
|
+
itemFor: (sessionId, itemId) => queueFor(sessionId).find(
|
|
437
|
+
(item) => String(item?.id) === String(itemId) || String(item?.rpcId ?? '') === String(itemId),
|
|
438
|
+
) ?? null,
|
|
439
|
+
/** Every queued item id, newest fold first — used to clear a queue honestly. */
|
|
440
|
+
itemIds: (sessionId) => (queues.get(sessionId) ?? []).map((item) => String(item?.id)),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Read the pending queue out of DSH's `inbox` projection value.
|
|
446
|
+
*
|
|
447
|
+
* A faithful port of the controller's own `queueItemsFromInbox`
|
|
448
|
+
* (`dsh-api-session-controller/lib/index.js`), because this is the **only**
|
|
449
|
+
* reliable source: the control stream pushes a `queue` frame when the inbox
|
|
450
|
+
* changes, but that frame is not guaranteed to reach us, and a fold cannot tell
|
|
451
|
+
* "the queue is empty" from "I never heard". Answering the controller from a
|
|
452
|
+
* stale fold left a phantom 队列中 row — the message had been answered, and the
|
|
453
|
+
* row describing it never went away.
|
|
454
|
+
*
|
|
455
|
+
* The value is `{ 'next-turn': Message[], 'next-step': Message[] }`, where each
|
|
456
|
+
* message carries its own id and, for a browser-submitted prompt, an `rpcId` —
|
|
457
|
+
* the controller's own id for it.
|
|
458
|
+
*
|
|
459
|
+
* @param inbox - the projection value, or anything else.
|
|
460
|
+
* @returns the controller's queued rows, in DSH's own order.
|
|
461
|
+
*/
|
|
462
|
+
export function queueItemsFromInbox(inbox) {
|
|
463
|
+
if (inbox === null || typeof inbox !== 'object') return [];
|
|
464
|
+
const items = [];
|
|
465
|
+
for (const message of Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : []) {
|
|
466
|
+
items.push(queueItemOf(message, 'queued'));
|
|
467
|
+
}
|
|
468
|
+
for (const message of Array.isArray(inbox['next-step']) ? inbox['next-step'] : []) {
|
|
469
|
+
const placement = message?.source?.kind === 'user' ? 'steering' : 'context';
|
|
470
|
+
items.push(queueItemOf(message, placement));
|
|
471
|
+
}
|
|
472
|
+
return items.filter((item) => item !== null);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** One queued row, or null when the message cannot be addressed. */
|
|
476
|
+
function queueItemOf(message, placement) {
|
|
477
|
+
const id = message?.id === undefined || message?.id === null ? null : String(message.id);
|
|
478
|
+
if (id === null) return null;
|
|
479
|
+
const source = message?.source;
|
|
480
|
+
const rpcId = source?.kind === 'user' && typeof source.rpcId === 'string' && source.rpcId !== ''
|
|
481
|
+
? source.rpcId
|
|
482
|
+
: undefined;
|
|
483
|
+
return {
|
|
484
|
+
id,
|
|
485
|
+
placement,
|
|
486
|
+
...(rpcId === undefined ? {} : { rpcId }),
|
|
487
|
+
message: { id, content: Array.isArray(message?.content) ? message.content : [] },
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Read the goal projection value out of an `observeSession` snapshot.
|
|
493
|
+
*
|
|
494
|
+
* Same reasoning as the queue: the projection stream is a convenience that may
|
|
495
|
+
* never speak, while this read is authoritative — a goal existed, the status
|
|
496
|
+
* channel answered `null` for twenty seconds straight.
|
|
497
|
+
* @param projections - a `ProjectionSnapshot`, or anything else.
|
|
498
|
+
* @returns the goal projection value, or undefined when the snapshot has none.
|
|
499
|
+
*/
|
|
500
|
+
export function goalFromProjections(projections) {
|
|
501
|
+
const values = projections?.values;
|
|
502
|
+
if (values === null || typeof values !== 'object') return undefined;
|
|
503
|
+
if (!Object.prototype.hasOwnProperty.call(values, 'goal')) return undefined;
|
|
504
|
+
return values.goal ?? null;
|
|
505
|
+
}
|