dsh-plugin-dshm 0.0.0-stage → 0.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/README.zh-CN.md +190 -0
- package/cordis.patch.yml +7 -0
- package/lib/auth/signature.d.ts +4 -0
- package/lib/auth/signature.js +24 -0
- package/lib/cli/index.d.ts +4 -0
- package/lib/cli/index.js +41 -0
- package/lib/client.js +419 -0
- package/lib/config.d.ts +10 -0
- package/lib/config.js +27 -0
- package/lib/discovery/advertiser.d.ts +45 -0
- package/lib/discovery/advertiser.js +141 -0
- package/lib/discovery/interfaces.d.ts +5 -0
- package/lib/discovery/interfaces.js +24 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.js +10 -0
- package/lib/host/local.d.ts +5 -0
- package/lib/host/local.js +122 -0
- package/lib/host/tailscale.d.ts +43 -0
- package/lib/host/tailscale.js +137 -0
- package/lib/host/types.d.ts +40 -0
- package/lib/host/types.js +1 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +98 -0
- package/lib/methods/index.d.ts +9 -0
- package/lib/methods/index.js +206 -0
- package/lib/pairing/state.d.ts +26 -0
- package/lib/pairing/state.js +89 -0
- package/lib/pairing/window.d.ts +18 -0
- package/lib/pairing/window.js +57 -0
- package/lib/projection/client.d.mts +2 -0
- package/lib/projection/client.mjs +11 -0
- package/lib/projection/files.d.ts +33 -0
- package/lib/projection/files.js +278 -0
- package/lib/projection/fold-recording.d.mts +50 -0
- package/lib/projection/fold-recording.mjs +440 -0
- package/lib/projection/host.d.ts +6 -0
- package/lib/projection/host.js +86 -0
- package/lib/projection/ids.d.mts +1 -0
- package/lib/projection/ids.mjs +1 -0
- package/lib/projection/index.d.ts +2 -0
- package/lib/projection/index.js +2 -0
- package/lib/projection/interactions.d.ts +10 -0
- package/lib/projection/interactions.js +73 -0
- package/lib/projection/native.d.ts +114 -0
- package/lib/projection/native.js +903 -0
- package/lib/protocol/THIRD_PARTY_LICENSES.txt +135 -0
- package/lib/protocol/dmp.d.ts +402 -0
- package/lib/protocol/dmp.js +1 -0
- package/lib/protocol/schema/dmp-v1/common.schema.json +88 -0
- package/lib/protocol/schema/dmp-v1/events.schema.json +107 -0
- package/lib/protocol/schema/dmp-v1/frames.schema.json +280 -0
- package/lib/protocol/schema/dmp-v1/methods.schema.json +584 -0
- package/lib/protocol/schema/dmp-v1/models.schema.json +674 -0
- package/lib/protocol/scripts/contract.mjs +8106 -0
- package/lib/push/controller.d.ts +64 -0
- package/lib/push/controller.js +290 -0
- package/lib/push/observer.d.ts +51 -0
- package/lib/push/observer.js +266 -0
- package/lib/push/payload.d.ts +15 -0
- package/lib/push/payload.js +18 -0
- package/lib/push/store.d.ts +17 -0
- package/lib/push/store.js +75 -0
- package/lib/server/index.d.ts +30 -0
- package/lib/server/index.js +500 -0
- package/lib/server/rate-limit.d.ts +7 -0
- package/lib/server/rate-limit.js +19 -0
- package/lib/server/uploads.d.ts +21 -0
- package/lib/server/uploads.js +74 -0
- package/lib/types.d.ts +92 -0
- package/lib/types.js +1 -0
- package/lib/ui/card.d.mts +3 -0
- package/lib/ui/card.mjs +59 -0
- package/lib/ui/copy.d.mts +146 -0
- package/lib/ui/copy.mjs +106 -0
- package/lib/ui/remote-setup.d.mts +81 -0
- package/lib/ui/remote-setup.mjs +75 -0
- package/package.json +67 -4
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// This adapter is deliberately the only layer that interprets native records.
|
|
2
|
+
// The recorder driver, fixtures, mock host, and clients exchange DMP afterwards.
|
|
3
|
+
import { deliverableId } from './ids.mjs';
|
|
4
|
+
const utf8 = (text, limit) => { const bytes = Buffer.from(text); let end = Math.min(bytes.length, limit); while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
5
|
+
end--; return bytes.subarray(0, end).toString('utf8'); };
|
|
6
|
+
const iso = value => new Date(typeof value === 'number' ? value : value ?? 0).toISOString();
|
|
7
|
+
const textOf = message => (message?.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
8
|
+
const relative = value => value?.replace(/^\/fixture\/workspace\/?/, '') || '.';
|
|
9
|
+
const kindOf = name => ({ bash: 'shell', write: 'edit', read: 'read', ask_user_question: 'question', present: 'present' }[name] ?? 'other');
|
|
10
|
+
const modelOf = native => ({ provider: native.provider, model: native.model, ...(native.reasoningEffort ? { effort: native.reasoningEffort } : {}) });
|
|
11
|
+
export function createProjectionFold(metadata, options = {}) {
|
|
12
|
+
const records = options.relatedRecords ?? [];
|
|
13
|
+
const settled = options.settled ?? new Map();
|
|
14
|
+
const frames = [], replay = [], mock = { files: [{ path: 'fixture-change.txt', mime: 'text/plain' }], changes: [], jobs: [], jobOutput: [] };
|
|
15
|
+
if (metadata.scenario.startsWith('tools-'))
|
|
16
|
+
mock.files.push({ path: 'download.txt', mime: 'text/plain' });
|
|
17
|
+
const push = (from, frame, atMs = 0) => {
|
|
18
|
+
if (frame.t === 'res')
|
|
19
|
+
frame.ok = true;
|
|
20
|
+
if (frame.t === 'ev') {
|
|
21
|
+
frame.sub = frame.subId;
|
|
22
|
+
frame.kind = frame.event;
|
|
23
|
+
delete frame.subId;
|
|
24
|
+
delete frame.event;
|
|
25
|
+
}
|
|
26
|
+
replay.push({ frameIndex: frames.length, delayMs: Math.max(0, atMs - previousAt) });
|
|
27
|
+
previousAt = atMs;
|
|
28
|
+
frames.push({ from, frame });
|
|
29
|
+
};
|
|
30
|
+
let previousAt = 0, currentSeq = 0, lastTime = 0, requestId = 2;
|
|
31
|
+
const subId = 'sub-session';
|
|
32
|
+
const emit = (event, data, atMs) => push('server', { t: 'ev', subId, event, data }, atMs);
|
|
33
|
+
const items = new Map(), calls = new Map(), attempts = new Map(), pending = new Map();
|
|
34
|
+
const approvalIds = new Map();
|
|
35
|
+
const removed = new Map();
|
|
36
|
+
const origins = new Map();
|
|
37
|
+
const activeStreams = new Map();
|
|
38
|
+
const creationPositions = new Map();
|
|
39
|
+
const nativeTimes = [];
|
|
40
|
+
let currentQueue = [];
|
|
41
|
+
const snapshot = options.snapshot;
|
|
42
|
+
currentSeq = snapshot?.cursor ?? (metadata.live ? -1 : 0);
|
|
43
|
+
const baselineEvents = snapshot?.records?.map(r => r.event) ?? [];
|
|
44
|
+
lastTime = baselineEvents.at(-1)?.time ?? Date.parse(metadata.recordedAt);
|
|
45
|
+
const summary = { sessionId: metadata.sessionId, workspaceId: 'workspace-fixture', title: metadata.title, status: 'idle', pendingInteractions: 0, updatedAt: iso(lastTime), archived: false, pinned: false, model: { provider: 'dshm-fixtures', model: 'fixture', effort: 'low' }, ...(metadata.summary ?? {}) };
|
|
46
|
+
if (metadata.live && !metadata.summary?.model)
|
|
47
|
+
delete summary.model;
|
|
48
|
+
let permission = metadata.permission;
|
|
49
|
+
const session = () => ({ summary: structuredClone(summary), ...(metadata.live ? { ...(metadata.cwd ? { cwd: metadata.cwd } : {}), ...(permission ? { permission: { current: permission } } : {}) } : { cwd: '/fixture/workspace', permission: { current: 'workspace-write' } }) });
|
|
50
|
+
const update = at => { summary.updatedAt = iso(lastTime); emit('session.update', { session: session() }, at); };
|
|
51
|
+
// Display order is fixed when an item is first created; later updates (results,
|
|
52
|
+
// settlement) raise item.seq but must never move the item.
|
|
53
|
+
const createdAtSeq = new Map();
|
|
54
|
+
const orderOf = itemId => {
|
|
55
|
+
const existing = items.get(itemId)?.order;
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
const seq = creationPositions.get(itemId) ?? currentSeq;
|
|
59
|
+
const index = createdAtSeq.get(seq) ?? 0;
|
|
60
|
+
createdAtSeq.set(seq, index + 1);
|
|
61
|
+
return { seq, index };
|
|
62
|
+
};
|
|
63
|
+
const inline = item => {
|
|
64
|
+
const value = structuredClone(item);
|
|
65
|
+
if (['user_message', 'assistant_message', 'reasoning'].includes(value.kind) && Buffer.byteLength(value.text) > 262144) {
|
|
66
|
+
value.truncated = { totalBytes: Buffer.byteLength(value.text) };
|
|
67
|
+
value.text = utf8(value.text, 262144);
|
|
68
|
+
}
|
|
69
|
+
if (value.kind === 'tool_call') {
|
|
70
|
+
value.input.raw = utf8(value.input.raw, 8192);
|
|
71
|
+
if (value.output && Buffer.byteLength(value.output.text) > 65536) {
|
|
72
|
+
value.output.totalBytes = Buffer.byteLength(value.output.text);
|
|
73
|
+
value.output.truncated = true;
|
|
74
|
+
value.output.text = utf8(value.output.text, 65536);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
const upsert = (item, at) => {
|
|
80
|
+
item.order = orderOf(item.itemId);
|
|
81
|
+
items.set(item.itemId, item);
|
|
82
|
+
removed.delete(item.itemId);
|
|
83
|
+
const source = origins.get(item.itemId) ?? new Set();
|
|
84
|
+
source.add(currentSeq);
|
|
85
|
+
origins.set(item.itemId, source);
|
|
86
|
+
emit('timeline.upsert', { item: inline(item), throughSeq: currentSeq }, at);
|
|
87
|
+
};
|
|
88
|
+
const base = (id, kind, event = {}) => ({ itemId: id, seq: currentSeq, time: iso(event.time ?? lastTime), kind, ...(Number.isInteger(event.data?.turn) ? { turn: event.data.turn } : {}) });
|
|
89
|
+
push('client', { t: 'req', id: 1, method: 'session.watch', params: { sessionId: metadata.sessionId } });
|
|
90
|
+
push('server', { t: 'res', id: 1, result: { subId, session: session(), items: [], cursor: { throughSeq: currentSeq }, hasMoreBefore: false, resumed: false, removedItemIds: [], interactions: [], queue: [] } });
|
|
91
|
+
let jobSubscribed = false, jobsSubscribed = false, lastChangeId, checkpoint = currentSeq;
|
|
92
|
+
const jobOf = native => ({ jobId: 'job-1', sessionId: metadata.sessionId, title: native.label ?? 'Background shell job', status: native.status, output: native.output ?? { earliest: 0, total: 0 }, ...(native.startedAt ? { startedAt: iso(native.startedAt) } : {}), ...(native.finishedAt ? { endedAt: iso(native.finishedAt) } : {}) });
|
|
93
|
+
const accept = record => {
|
|
94
|
+
const recordStart = frames.length;
|
|
95
|
+
const { source, frame, atMs } = record;
|
|
96
|
+
if (source === 'session' && frame.type === 'event') {
|
|
97
|
+
const event = frame.event, data = event.data;
|
|
98
|
+
currentSeq = event.seq;
|
|
99
|
+
lastTime = event.time;
|
|
100
|
+
if (metadata.live && event.surfaceOp?.op === 'replace' && event.type !== 'tool/result') {
|
|
101
|
+
const replaced = new Set(event.sourceEventSeqs ?? []);
|
|
102
|
+
for (const [itemId, source] of origins)
|
|
103
|
+
if ([...source].some(seq => replaced.has(seq) || seq >= event.surfaceOp.startSeq && seq <= event.surfaceOp.endSeq)) {
|
|
104
|
+
items.delete(itemId);
|
|
105
|
+
origins.delete(itemId);
|
|
106
|
+
removed.set(itemId, currentSeq);
|
|
107
|
+
emit('timeline.remove', { itemId, throughSeq: currentSeq }, atMs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (metadata.live && event.type === 'agent/inbox/spliced') {
|
|
111
|
+
const group = currentQueue.filter(q => q.mode === data.target);
|
|
112
|
+
group.splice(data.start, data.removedCount ?? 0, ...(data.inserted ?? []).map(q => ({ queueItemId: q.id, text: textOf(q), mode: data.target })));
|
|
113
|
+
currentQueue = [...currentQueue.filter(q => q.mode !== data.target), ...group];
|
|
114
|
+
emit('queue.update', { queue: structuredClone(currentQueue) }, atMs);
|
|
115
|
+
}
|
|
116
|
+
if (metadata.live && event.type === 'session/title') {
|
|
117
|
+
summary.title = data.title ?? data.value ?? null;
|
|
118
|
+
update(atMs);
|
|
119
|
+
}
|
|
120
|
+
if (metadata.live && event.type === 'permission/preset') {
|
|
121
|
+
permission = data.preset;
|
|
122
|
+
update(atMs);
|
|
123
|
+
upsert({ ...base(`permission-${currentSeq}`, 'notice'), level: 'info', text: `Permission selected: ${permission}`, code: 'permission-changed' }, atMs);
|
|
124
|
+
}
|
|
125
|
+
if (metadata.live && event.type === 'approval/asked') {
|
|
126
|
+
const call = calls.get(data.callId);
|
|
127
|
+
const id = `i-${metadata.sessionId}-${data.callId ?? data.id}`;
|
|
128
|
+
approvalIds.set(data.id, id);
|
|
129
|
+
if (call) {
|
|
130
|
+
call.status = 'awaiting-approval';
|
|
131
|
+
call.seq = currentSeq;
|
|
132
|
+
upsert(call, atMs);
|
|
133
|
+
}
|
|
134
|
+
upsert({ ...base(`interaction-${id}`, 'interaction', event), interactionId: id, interactionKind: 'approval', state: 'pending', summary: call?.title ?? data.toolName ?? 'Approval', ...(data.callId ? { toolCallId: data.callId } : {}) }, atMs);
|
|
135
|
+
}
|
|
136
|
+
if (metadata.live && event.type === 'approval/decided') {
|
|
137
|
+
const pendingItem = items.get(`interaction-${approvalIds.get(data.id)}`);
|
|
138
|
+
if (pendingItem) {
|
|
139
|
+
pendingItem.seq = currentSeq;
|
|
140
|
+
pendingItem.state = 'answered';
|
|
141
|
+
pendingItem.outcome = data.outcome === 'rejected' ? 'deny' : 'allow-once';
|
|
142
|
+
upsert(pendingItem, atMs);
|
|
143
|
+
const call = calls.get(pendingItem.toolCallId);
|
|
144
|
+
if (call && data.outcome === 'rejected')
|
|
145
|
+
call.status = 'denied';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (event.type === 'turn/start') {
|
|
149
|
+
summary.status = 'running';
|
|
150
|
+
update(atMs);
|
|
151
|
+
}
|
|
152
|
+
if (event.type === 'model/selection') {
|
|
153
|
+
summary.model = modelOf(data);
|
|
154
|
+
update(atMs);
|
|
155
|
+
upsert({ ...base(`model-${currentSeq}`, 'notice'), level: 'info', text: `Model selected: ${data.provider}/${data.model} (${data.reasoningEffort})`, code: 'model-changed' }, atMs);
|
|
156
|
+
}
|
|
157
|
+
if (event.type === 'user/message' && data.source?.kind === 'user')
|
|
158
|
+
upsert({ ...base(`user-${event.seq}`, 'user_message', event), text: textOf(data), attachments: metadata.live ? (data.content ?? []).filter(part => ['image', 'file'].includes(part.type)).map(part => ({ name: part.attachment?.name ?? (part.type === 'image' ? 'Image' : 'File'), ...(part.attachment?.mediaType ? { mime: part.attachment.mediaType } : {}), ...(Number.isInteger(part.attachment?.bytes) ? { size: part.attachment.bytes } : {}) })) : [] }, atMs);
|
|
159
|
+
if (event.type === 'assistant/message') {
|
|
160
|
+
const kinds = metadata.live ? [...new Set((data.message.content ?? []).map(part => part.type).filter(kind => ['text', 'reasoning'].includes(kind)))] : ['text'];
|
|
161
|
+
for (const blockKind of kinds) {
|
|
162
|
+
const text = (data.message.content ?? []).filter(part => part.type === blockKind).map(part => part.text).join('\n');
|
|
163
|
+
if (!text)
|
|
164
|
+
continue;
|
|
165
|
+
const attemptId = settled.get(event.seq) ?? (metadata.live ? `t${data.turn}-s${data.step}` : undefined);
|
|
166
|
+
const prefix = blockKind === 'reasoning' ? 'reasoning' : 'assistant';
|
|
167
|
+
const itemId = attemptId ? `${prefix}-${attemptId}` : `${prefix}-${event.seq}`;
|
|
168
|
+
if (metadata.live && !creationPositions.has(itemId)) {
|
|
169
|
+
const first = (data.stream ?? []).find(chunk => chunk.type.endsWith('-chunks') || chunk.chunk?.type === 'block-start');
|
|
170
|
+
const firstTime = first?.time0 ?? first?.time;
|
|
171
|
+
const before = nativeTimes.findLast(entry => entry.time <= firstTime);
|
|
172
|
+
creationPositions.set(itemId, before?.seq ?? event.seq);
|
|
173
|
+
}
|
|
174
|
+
upsert({ ...base(itemId, blockKind === 'reasoning' ? 'reasoning' : 'assistant_message', event), text, state: data.interrupted ? 'cancelled' : 'complete', ...(blockKind === 'text' && summary.model ? { model: structuredClone(summary.model) } : {}) }, atMs);
|
|
175
|
+
if (metadata.live)
|
|
176
|
+
attempts.delete(`${prefix}-${attemptId}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (event.type === 'tool/call') {
|
|
180
|
+
let args = {};
|
|
181
|
+
try {
|
|
182
|
+
args = JSON.parse(data.arguments);
|
|
183
|
+
}
|
|
184
|
+
catch { /* Preserve truncated input as raw. */ }
|
|
185
|
+
const item = { ...base(`tool-${data.callId}`, 'tool_call', event), callId: data.callId, tool: { name: data.name, kind: kindOf(data.name) }, title: args.description ?? `${data.name}${args.file_path ? ` ${relative(args.file_path)}` : ''}`, input: { raw: data.arguments.slice(0, 8192), ...(args.command ? { command: args.command } : {}), ...(args.file_path ? { path: relative(args.file_path) } : {}) }, status: 'running' };
|
|
186
|
+
if (metadata.live && [...pending.values()].some(interaction => interaction.kind === 'approval' && interaction.toolCallId === data.callId))
|
|
187
|
+
item.status = 'awaiting-approval';
|
|
188
|
+
if (args.run_in_background && !metadata.live)
|
|
189
|
+
item.jobId = 'job-1';
|
|
190
|
+
calls.set(data.callId, item);
|
|
191
|
+
upsert(item, atMs);
|
|
192
|
+
}
|
|
193
|
+
if (metadata.live && event.type === 'job/registered') {
|
|
194
|
+
const item = calls.get(data.toolCall?.callId);
|
|
195
|
+
if (item) {
|
|
196
|
+
item.jobId = data.jobId;
|
|
197
|
+
item.seq = currentSeq;
|
|
198
|
+
upsert(item, atMs);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (event.type === 'tool/result') {
|
|
202
|
+
const item = calls.get(data.message.toolCallId);
|
|
203
|
+
if (item) {
|
|
204
|
+
item.seq = currentSeq;
|
|
205
|
+
item.status = data.message.isError ? (item.status === 'denied' ? 'denied' : 'failed') : 'succeeded';
|
|
206
|
+
item.output = { text: metadata.live ? textOf(data.message) : textOf(data.message).slice(0, 65536), truncated: !metadata.live && textOf(data.message).length > 65536, isError: data.message.isError === true };
|
|
207
|
+
upsert(item, atMs);
|
|
208
|
+
for (const interaction of items.values())
|
|
209
|
+
if (interaction.kind === 'interaction' && interaction.toolCallId === item.callId && interaction.state === 'answered') {
|
|
210
|
+
interaction.seq = currentSeq;
|
|
211
|
+
upsert(interaction, atMs);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (event.type === 'workspace/changes') {
|
|
216
|
+
lastChangeId = `change-${event.seq}`;
|
|
217
|
+
const native = records.find(r => r.source === 'changes.summary')?.frame;
|
|
218
|
+
upsert({ ...base(lastChangeId, 'changes', event), changeId: lastChangeId, fileCount: native?.total ?? 0, added: native?.added ?? 0, deleted: native?.deleted ?? 0, available: Boolean(native) }, atMs);
|
|
219
|
+
}
|
|
220
|
+
if (event.type === 'deliverables/presented')
|
|
221
|
+
upsert({ ...base(`deliverables-${event.seq}`, 'deliverables', event), deliverables: data.files.map((file, index) => ({ deliverableId: metadata.live ? deliverableId(metadata.sessionId, event.seq, index) : `deliverable-${event.seq}-${index}`, name: relative(file.path).split('/').at(-1), path: relative(file.path), ...(file.description ? { description: file.description } : {}) })) }, atMs);
|
|
222
|
+
if (event.type === 'turn/end') {
|
|
223
|
+
const reason = data.reason?.kind === 'aborted' ? 'cancelled' : data.reason?.kind === 'error' ? 'error' : 'completed';
|
|
224
|
+
for (const [id, item] of attempts)
|
|
225
|
+
if (item.state === 'streaming') {
|
|
226
|
+
item.seq = currentSeq;
|
|
227
|
+
item.state = reason === 'cancelled' ? 'cancelled' : reason === 'error' ? 'failed' : 'complete';
|
|
228
|
+
upsert(item, atMs);
|
|
229
|
+
attempts.delete(id);
|
|
230
|
+
}
|
|
231
|
+
upsert({ ...base(`turn-end-${event.seq}`, 'turn_end', event), reason }, atMs);
|
|
232
|
+
summary.status = reason === 'error' ? 'error' : 'idle';
|
|
233
|
+
update(atMs);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (source === 'session' && frame.type === 'assistant-stream') {
|
|
237
|
+
const stream = frame.frame;
|
|
238
|
+
if (metadata.live && stream.type === 'start') {
|
|
239
|
+
const identity = `t${stream.turn}-s${stream.step}`;
|
|
240
|
+
activeStreams.set(stream.attemptId, { ...stream, identity });
|
|
241
|
+
creationPositions.set(`assistant-${identity}`, stream.startedAfterSeq);
|
|
242
|
+
creationPositions.set(`reasoning-${identity}`, stream.startedAfterSeq);
|
|
243
|
+
}
|
|
244
|
+
const streamId = metadata.live ? activeStreams.get(stream.attemptId)?.identity ?? stream.attemptId : stream.attemptId;
|
|
245
|
+
if (stream.type === 'chunk' && ['text-delta', ...(metadata.live ? ['reasoning-delta'] : [])].includes(stream.chunk?.type)) {
|
|
246
|
+
const attemptKey = metadata.live ? `${stream.chunk.type === 'reasoning-delta' ? 'reasoning' : 'assistant'}-${streamId}` : streamId;
|
|
247
|
+
let item = attempts.get(attemptKey);
|
|
248
|
+
if (!item) {
|
|
249
|
+
item = { ...base(`${stream.chunk.type === 'reasoning-delta' ? 'reasoning' : 'assistant'}-${streamId}`, stream.chunk.type === 'reasoning-delta' ? 'reasoning' : 'assistant_message'), text: '', state: 'streaming', ...(stream.chunk.type === 'text-delta' && summary.model ? { model: structuredClone(summary.model) } : {}) };
|
|
250
|
+
attempts.set(attemptKey, item);
|
|
251
|
+
upsert(item, atMs);
|
|
252
|
+
}
|
|
253
|
+
const before = Buffer.byteLength(item.text);
|
|
254
|
+
item.text += stream.chunk.text;
|
|
255
|
+
const append = metadata.live ? utf8(stream.chunk.text, Math.max(0, 262144 - before)) : stream.chunk.text;
|
|
256
|
+
if (append)
|
|
257
|
+
emit('timeline.delta', { itemId: item.itemId, field: 'text', append }, atMs);
|
|
258
|
+
}
|
|
259
|
+
if (stream.type === 'end' && stream.outcome?.kind === 'committed') {
|
|
260
|
+
attempts.delete(streamId);
|
|
261
|
+
if (metadata.live) {
|
|
262
|
+
attempts.delete(`assistant-${streamId}`);
|
|
263
|
+
attempts.delete(`reasoning-${streamId}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (metadata.live && source === 'interaction-expire') {
|
|
268
|
+
for (const id of frame.ids) {
|
|
269
|
+
const item = items.get(`interaction-${id}`);
|
|
270
|
+
if (item?.state === 'pending' && !pending.has(id)) {
|
|
271
|
+
item.state = 'expired';
|
|
272
|
+
item.seq = currentSeq;
|
|
273
|
+
upsert(item, atMs);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
summary.status = frame.status;
|
|
277
|
+
summary.pendingInteractions = pending.size;
|
|
278
|
+
update(atMs);
|
|
279
|
+
}
|
|
280
|
+
if (metadata.live && source === 'interaction-warning') {
|
|
281
|
+
const itemId = 'interaction-unidentified';
|
|
282
|
+
if (!items.has(itemId))
|
|
283
|
+
upsert({ ...base(itemId, 'notice'), level: 'warning', text: `${frame.kind === 'approval' ? 'An approval' : 'A question'} is waiting on your computer that DSHM can't identify. Answer it in DeepSeek Harness.` }, atMs);
|
|
284
|
+
summary.status = frame.status;
|
|
285
|
+
summary.pendingInteractions = pending.size;
|
|
286
|
+
update(atMs);
|
|
287
|
+
}
|
|
288
|
+
if (source === 'interaction' && frame.type === 'waterfall') {
|
|
289
|
+
const tool = calls.get(frame.request.callId) ?? (metadata.live ? undefined : [...calls.values()].findLast(call => ['running', 'awaiting-approval'].includes(call.status)));
|
|
290
|
+
const callId = metadata.live ? frame.request.callId : tool?.callId;
|
|
291
|
+
const kind = frame.event === 'approval/request' ? 'approval' : 'question';
|
|
292
|
+
const toolName = tool?.tool.name ?? (metadata.live ? frame.request.toolName ?? 'unknown' : 'bash');
|
|
293
|
+
const interaction = { interactionId: frame.eventId, sessionId: metadata.sessionId, kind, blocking: true, createdAt: iso(lastTime), ...(callId ? { toolCallId: callId } : {}), ...(kind === 'approval' ? { approval: { toolName, toolKind: tool?.tool.kind ?? (metadata.live ? kindOf(toolName) : 'shell'), title: tool?.title ?? (metadata.live ? 'Approval' : 'Run a shell command'), ...(tool?.input.command ? { command: tool.input.command } : {}), ...(frame.request.reason ? { reason: frame.request.reason } : {}) } } : { questions: (frame.request.questions ?? []).map(q => ({ questionId: q.id, question: q.question, options: q.options ?? [], multiSelect: q.multiSelect ?? false, ...(q.header ? { header: q.header } : {}) })) }) };
|
|
294
|
+
pending.set(frame.eventId, interaction);
|
|
295
|
+
if (tool) {
|
|
296
|
+
tool.status = kind === 'approval' ? 'awaiting-approval' : 'running';
|
|
297
|
+
tool.seq = currentSeq;
|
|
298
|
+
upsert(tool, atMs);
|
|
299
|
+
}
|
|
300
|
+
upsert({ ...base(`interaction-${frame.eventId}`, 'interaction'), interactionId: frame.eventId, interactionKind: kind, state: 'pending', summary: kind === 'approval' ? interaction.approval.title : interaction.questions.map(q => q.question).join('\n'), ...(callId ? { toolCallId: callId } : {}) }, atMs);
|
|
301
|
+
summary.status = 'needs-input';
|
|
302
|
+
summary.pendingInteractions = pending.size;
|
|
303
|
+
update(atMs);
|
|
304
|
+
emit('interaction.open', { interaction }, atMs);
|
|
305
|
+
}
|
|
306
|
+
if (metadata.live && source === 'interaction' && frame.type === 'cancel') {
|
|
307
|
+
const interaction = pending.get(frame.eventId);
|
|
308
|
+
if (interaction) {
|
|
309
|
+
pending.delete(frame.eventId);
|
|
310
|
+
const item = items.get(`interaction-${frame.eventId}`);
|
|
311
|
+
if (item) {
|
|
312
|
+
item.state = 'cancelled';
|
|
313
|
+
item.seq = currentSeq;
|
|
314
|
+
upsert(item, atMs);
|
|
315
|
+
}
|
|
316
|
+
summary.pendingInteractions = pending.size;
|
|
317
|
+
if (summary.status === 'needs-input')
|
|
318
|
+
summary.status = pending.size ? 'needs-input' : 'running';
|
|
319
|
+
emit('interaction.close', { interactionId: frame.eventId, reason: 'cancelled' }, atMs);
|
|
320
|
+
update(atMs);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (source === 'answer') {
|
|
324
|
+
const interaction = pending.get(frame.eventId);
|
|
325
|
+
if (!interaction)
|
|
326
|
+
return;
|
|
327
|
+
const answer = interaction.kind === 'approval' ? { kind: 'approval', decision: frame.value === 'rejected' ? 'deny' : 'allow-once' } : { kind: 'question', answers: frame.value.answers.map(a => ({ questionId: a.id, selected: a.selected })) };
|
|
328
|
+
const id = requestId++;
|
|
329
|
+
push('client', { t: 'req', id, method: 'interaction.answer', params: { interactionId: frame.eventId, answer } }, atMs);
|
|
330
|
+
push('server', { t: 'res', id, result: { settled: true } }, atMs);
|
|
331
|
+
emit('interaction.close', { interactionId: frame.eventId, reason: 'answered' }, atMs);
|
|
332
|
+
const item = items.get(`interaction-${frame.eventId}`);
|
|
333
|
+
item.state = 'answered';
|
|
334
|
+
item.seq = currentSeq;
|
|
335
|
+
item.outcome = interaction.kind === 'approval' ? answer.decision : 'answered';
|
|
336
|
+
upsert(item, atMs);
|
|
337
|
+
if (interaction.kind === 'approval' && answer.decision === 'deny' && calls.has(interaction.toolCallId))
|
|
338
|
+
calls.get(interaction.toolCallId).status = 'denied';
|
|
339
|
+
pending.delete(frame.eventId);
|
|
340
|
+
summary.pendingInteractions = pending.size;
|
|
341
|
+
summary.status = 'running';
|
|
342
|
+
update(atMs);
|
|
343
|
+
}
|
|
344
|
+
if (source === 'queue') {
|
|
345
|
+
const queue = ['next-turn', 'next-step'].flatMap(mode => (frame[mode] ?? []).map((q, index) => ({ queueItemId: q.id ?? `queue-${index}`, text: (q.content ?? q.input?.content ?? []).filter(p => p.type === 'text').map(p => p.text).join('\n'), mode })));
|
|
346
|
+
currentQueue = queue;
|
|
347
|
+
emit('queue.update', { queue }, atMs);
|
|
348
|
+
}
|
|
349
|
+
if (metadata.live && source === 'permission') {
|
|
350
|
+
permission = frame.current;
|
|
351
|
+
update(atMs);
|
|
352
|
+
}
|
|
353
|
+
if (metadata.live && source === 'change-state') {
|
|
354
|
+
const item = items.get(`change-${frame.seq}`);
|
|
355
|
+
if (item) {
|
|
356
|
+
item.available = frame.available;
|
|
357
|
+
for (const key of ['fileCount', 'added', 'deleted'])
|
|
358
|
+
if (Number.isInteger(frame[key]))
|
|
359
|
+
item[key] = frame[key];
|
|
360
|
+
item.seq = currentSeq;
|
|
361
|
+
upsert(item, atMs);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (source === 'changes.summary') {
|
|
365
|
+
const result = { files: frame.files.map((file, index) => ({ index, path: relative(file.path), display: relative(file.path), status: 'modified', added: file.added, deleted: file.deleted })), added: frame.added, deleted: frame.deleted };
|
|
366
|
+
mock.changes.push({ changeId: lastChangeId, ...result });
|
|
367
|
+
const id = requestId++;
|
|
368
|
+
push('client', { t: 'req', id, method: 'changes.summary', params: { sessionId: metadata.sessionId, changeId: lastChangeId } }, atMs);
|
|
369
|
+
push('server', { t: 'res', id, result }, atMs);
|
|
370
|
+
}
|
|
371
|
+
if (source === 'changes.diff') {
|
|
372
|
+
const change = mock.changes.at(-1);
|
|
373
|
+
if (change) {
|
|
374
|
+
change.diff = { kind: frame.kind, path: relative(frame.path), ...(frame.hunks ? { hunks: frame.hunks, coarse: frame.coarse } : {}) };
|
|
375
|
+
const id = requestId++;
|
|
376
|
+
push('client', { t: 'req', id, method: 'changes.diff', params: { sessionId: metadata.sessionId, changeId: change.changeId, index: 0 } }, atMs);
|
|
377
|
+
push('server', { t: 'res', id, result: { diff: change.diff } }, atMs);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (source === 'jobs' && frame.type === 'rows') {
|
|
381
|
+
const jobs = frame.jobs.map(jobOf);
|
|
382
|
+
mock.jobs = jobs;
|
|
383
|
+
if (!jobsSubscribed) {
|
|
384
|
+
const id = requestId++;
|
|
385
|
+
push('client', { t: 'req', id, method: 'jobs.watch', params: { sessionId: metadata.sessionId } }, atMs);
|
|
386
|
+
push('server', { t: 'res', id, result: { subId: 'sub-jobs', jobs } }, atMs);
|
|
387
|
+
jobsSubscribed = true;
|
|
388
|
+
}
|
|
389
|
+
else
|
|
390
|
+
push('server', { t: 'ev', subId: 'sub-jobs', event: 'jobs.replace', data: { jobs } }, atMs);
|
|
391
|
+
}
|
|
392
|
+
if (source === 'job' && frame.type === 'opened' && !jobSubscribed) {
|
|
393
|
+
const id = requestId++;
|
|
394
|
+
push('client', { t: 'req', id, method: 'job.watch', params: { sessionId: metadata.sessionId, jobId: 'job-1', fromByte: 0 } }, atMs);
|
|
395
|
+
push('server', { t: 'res', id, result: { subId: 'sub-job', job: jobOf(frame.job), from: frame.from } }, atMs);
|
|
396
|
+
jobSubscribed = true;
|
|
397
|
+
}
|
|
398
|
+
if (source === 'job' && frame.type === 'output') {
|
|
399
|
+
const chunks = frame.chunks.map(({ at, text, channel, gapBefore, lossy }) => ({ at, text, channel, ...(gapBefore !== undefined ? { gapBefore } : {}), ...(lossy !== undefined ? { lossy } : {}) }));
|
|
400
|
+
mock.jobOutput.push(...chunks);
|
|
401
|
+
push('server', { t: 'ev', subId: 'sub-job', event: 'job.output', data: { chunks, next: frame.next } }, atMs);
|
|
402
|
+
}
|
|
403
|
+
if (source === 'job' && frame.type === 'status')
|
|
404
|
+
push('server', { t: 'ev', subId: 'sub-job', event: 'job.status', data: { job: jobOf(frame.job) } }, atMs);
|
|
405
|
+
// A native record can affect several visible items. Advance the resumable
|
|
406
|
+
// watermark only once all those replacements have been sent.
|
|
407
|
+
const updates = frames.slice(recordStart).filter(entry => ['timeline.upsert', 'timeline.remove'].includes(entry.frame.kind));
|
|
408
|
+
for (const entry of updates.slice(0, -1))
|
|
409
|
+
entry.frame.data.throughSeq = checkpoint;
|
|
410
|
+
if (updates.length)
|
|
411
|
+
checkpoint = updates.at(-1).frame.data.throughSeq;
|
|
412
|
+
if (metadata.live && source === 'session' && frame.type === 'event')
|
|
413
|
+
nativeTimes.push({ seq: frame.event.seq, time: frame.event.time });
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
accept(record) {
|
|
417
|
+
const from = frames.length;
|
|
418
|
+
accept(record);
|
|
419
|
+
const emitted = frames.slice(from);
|
|
420
|
+
if (metadata.live) {
|
|
421
|
+
frames.length = 0;
|
|
422
|
+
replay.length = 0;
|
|
423
|
+
}
|
|
424
|
+
return emitted;
|
|
425
|
+
},
|
|
426
|
+
state: () => ({ session: session(), items: [...items.values()].map(item => structuredClone(item)), inlineItems: [...items.values()].map(inline), interactions: [...pending.values()].map(i => structuredClone(i)), queue: structuredClone(currentQueue), throughSeq: currentSeq, removed: [...removed] }),
|
|
427
|
+
result: () => ({ transcript: { description: `${metadata.title}; recorded native DSH with deterministic model`, frames }, replay, mock }),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
export function foldRecording(records, metadata) {
|
|
431
|
+
const settled = new Map();
|
|
432
|
+
for (const { frame } of records)
|
|
433
|
+
if (frame.type === 'assistant-stream' && frame.frame.type === 'end' && frame.frame.outcome?.seq)
|
|
434
|
+
settled.set(frame.frame.outcome.seq, frame.frame.attemptId);
|
|
435
|
+
const snapshot = records.find(r => r.source === 'session' && r.frame.type === 'snapshot')?.frame;
|
|
436
|
+
const fold = createProjectionFold(metadata, { snapshot, settled, relatedRecords: records });
|
|
437
|
+
for (const record of records)
|
|
438
|
+
fold.accept(record);
|
|
439
|
+
return fold.result();
|
|
440
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostControls } from '../host/types.js';
|
|
2
|
+
/** Native exact routes inherit Connection's authenticated-owner and Host/Origin fence. */
|
|
3
|
+
export declare function installHostRoutes(context: unknown, controls: HostControls): void;
|
|
4
|
+
export declare const cliInject: string[];
|
|
5
|
+
/** The custom dshm profile owns its arguments; no installed launcher is patched. */
|
|
6
|
+
export declare function installCli(context: unknown, runner: (args: readonly string[]) => Promise<number>): void;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
2
|
+
const invalid = () => json({ error: { code: 'invalid-params', message: 'Invalid host-control request' } }, 400);
|
|
3
|
+
async function input(request, fields) {
|
|
4
|
+
if (request.headers.get('content-type')?.split(';', 1)[0]?.trim() !== 'application/json')
|
|
5
|
+
return;
|
|
6
|
+
if (Number(request.headers.get('content-length')) > 2048)
|
|
7
|
+
return;
|
|
8
|
+
const text = await request.text();
|
|
9
|
+
if (Buffer.byteLength(text) > 2048)
|
|
10
|
+
return;
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19
|
+
return;
|
|
20
|
+
const object = value;
|
|
21
|
+
if (Object.keys(object).some(key => !fields.includes(key)))
|
|
22
|
+
return;
|
|
23
|
+
return object;
|
|
24
|
+
}
|
|
25
|
+
/** Native exact routes inherit Connection's authenticated-owner and Host/Origin fence. */
|
|
26
|
+
export function installHostRoutes(context, controls) {
|
|
27
|
+
const ctx = context;
|
|
28
|
+
const route = (path, method, handler) => {
|
|
29
|
+
ctx.connection.fetch.register({ path: `/api/dshm/${path}`, methods: [method], requestBody: 'buffered', async fetch(request) {
|
|
30
|
+
try {
|
|
31
|
+
request.signal.throwIfAborted();
|
|
32
|
+
return await handler(request);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Native exceptions can contain paths or configuration. Only fixed UI
|
|
36
|
+
// copy crosses the privileged browser route.
|
|
37
|
+
return json({ error: { code: 'internal', message: 'Host operation failed' } }, 503);
|
|
38
|
+
}
|
|
39
|
+
} });
|
|
40
|
+
};
|
|
41
|
+
route('status', 'GET', () => json(controls.status()));
|
|
42
|
+
route('devices', 'GET', () => json({ devices: controls.devices() }));
|
|
43
|
+
route('remoteSetup', 'GET', async () => json(await controls.remoteSetup()));
|
|
44
|
+
route('openTailscale', 'POST', async (request) => {
|
|
45
|
+
if (!await input(request, []))
|
|
46
|
+
return invalid();
|
|
47
|
+
await controls.openTailscale();
|
|
48
|
+
return json({ opened: true });
|
|
49
|
+
});
|
|
50
|
+
route('pairRemote', 'POST', async (request) => {
|
|
51
|
+
if (!await input(request, []))
|
|
52
|
+
return invalid();
|
|
53
|
+
const pairing = await controls.pairRemote();
|
|
54
|
+
return pairing ? json(pairing) : json({ error: { code: 'not-ready', message: 'Connect Tailscale on this computer and retry.' } }, 409);
|
|
55
|
+
});
|
|
56
|
+
route('pair', 'POST', async (request) => {
|
|
57
|
+
if (!await input(request, []))
|
|
58
|
+
return invalid();
|
|
59
|
+
return json(await controls.pair());
|
|
60
|
+
});
|
|
61
|
+
route('cancelPair', 'POST', async (request) => {
|
|
62
|
+
if (!await input(request, []))
|
|
63
|
+
return invalid();
|
|
64
|
+
controls.cancelPair();
|
|
65
|
+
return json({});
|
|
66
|
+
});
|
|
67
|
+
route('revoke', 'POST', async (request) => {
|
|
68
|
+
const body = await input(request, ['deviceId']);
|
|
69
|
+
if (!body || typeof body.deviceId !== 'string' || !body.deviceId.length || body.deviceId.length > 256)
|
|
70
|
+
return invalid();
|
|
71
|
+
if (!controls.revoke(body.deviceId))
|
|
72
|
+
return json({ error: { code: 'not-found', message: 'Device was not found' } }, 404);
|
|
73
|
+
return json({ revoked: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export const cliInject = ['cmdlineArgs', 'appExit', 'appReady'];
|
|
77
|
+
/** The custom dshm profile owns its arguments; no installed launcher is patched. */
|
|
78
|
+
export function installCli(context, runner) {
|
|
79
|
+
const ctx = context;
|
|
80
|
+
const args = ctx.get('cmdlineArgs'), exit = ctx.get('appExit'), ready = ctx.get('appReady');
|
|
81
|
+
if (!args || !exit || !ready)
|
|
82
|
+
throw new Error('DSHM CLI requires the DSH profile launcher');
|
|
83
|
+
ctx.effect(() => ready.onReady(() => {
|
|
84
|
+
void runner(args.get()).then(exit, () => exit(1));
|
|
85
|
+
}), 'dshm: command lifetime');
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function deliverableId(sessionId: any, seq: any, index: any): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const deliverableId = (sessionId, seq, index) => `d.${Buffer.from(sessionId).toString('base64url')}.${seq}.${index}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DmpObject } from '../types.js';
|
|
2
|
+
import type { NativeRecord } from './fold-recording.mjs';
|
|
3
|
+
export interface InteractionIdentity {
|
|
4
|
+
id: string;
|
|
5
|
+
callId?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Native eventId addresses delivery only; it never contributes to DMP identity. */
|
|
8
|
+
export declare function interactionIdentity(sessionId: string, event: string, request: DmpObject, records: readonly NativeRecord[]): InteractionIdentity | undefined;
|
|
9
|
+
/** A follow snapshot can precede $events' pending-delivery replay after ready. */
|
|
10
|
+
export declare function expectedInteractionIds(sessionId: string, records: readonly NativeRecord[]): string[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
3
|
+
const string = (value) => typeof value === 'string' ? value : '';
|
|
4
|
+
const idFor = (sessionId, nativeId) => `i-${sessionId}-${nativeId}`;
|
|
5
|
+
function pending(records) {
|
|
6
|
+
const calls = new Map(), approvals = new Map();
|
|
7
|
+
for (const record of records) {
|
|
8
|
+
if (record.source !== 'session' || record.frame.type !== 'event')
|
|
9
|
+
continue;
|
|
10
|
+
const event = object(record.frame.event), data = object(event.data);
|
|
11
|
+
if (event.type === 'tool/call')
|
|
12
|
+
calls.set(string(data.callId), data);
|
|
13
|
+
if (event.type === 'tool/result')
|
|
14
|
+
calls.delete(string(object(data.message).toolCallId));
|
|
15
|
+
if (event.type === 'approval/asked')
|
|
16
|
+
approvals.set(string(data.id), data);
|
|
17
|
+
if (event.type === 'approval/decided')
|
|
18
|
+
approvals.delete(string(data.id));
|
|
19
|
+
if (event.type === 'turn/end') {
|
|
20
|
+
calls.clear();
|
|
21
|
+
approvals.clear();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { calls, approvals };
|
|
25
|
+
}
|
|
26
|
+
// Exact translation used by installed dsh-tool-ask-user questionRequest().
|
|
27
|
+
function questions(call) {
|
|
28
|
+
let input;
|
|
29
|
+
try {
|
|
30
|
+
input = object(JSON.parse(string(call.arguments)));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(input.questions))
|
|
36
|
+
return;
|
|
37
|
+
return input.questions.map(value => {
|
|
38
|
+
const question = object(value);
|
|
39
|
+
return { id: question.id, question: question.question,
|
|
40
|
+
...(question.header !== undefined ? { header: question.header } : {}),
|
|
41
|
+
...(question.options !== undefined ? { options: question.options } : {}),
|
|
42
|
+
...(question.multi_select !== undefined ? { multiSelect: question.multi_select } : {}),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Native eventId addresses delivery only; it never contributes to DMP identity. */
|
|
47
|
+
export function interactionIdentity(sessionId, event, request, records) {
|
|
48
|
+
const callId = string(request.callId) || string(object(request.wait).callId);
|
|
49
|
+
if (callId)
|
|
50
|
+
return { id: idFor(sessionId, callId), callId };
|
|
51
|
+
const { calls, approvals } = pending(records);
|
|
52
|
+
if (event === 'approval/request') {
|
|
53
|
+
const matches = [...approvals.values()].filter(audit => !audit.callId && audit.toolName === request.toolName && audit.reason === request.reason);
|
|
54
|
+
if (matches.length === 1 && string(matches[0].id))
|
|
55
|
+
return { id: idFor(sessionId, string(matches[0].id)) };
|
|
56
|
+
}
|
|
57
|
+
else if (event === 'user-questions/request' && Array.isArray(request.questions)) {
|
|
58
|
+
const matches = [...calls.values()].filter(call => call.name === 'ask_user_question' && isDeepStrictEqual(questions(call), request.questions));
|
|
59
|
+
if (matches.length === 1 && string(matches[0].callId)) {
|
|
60
|
+
const callId = string(matches[0].callId);
|
|
61
|
+
return { id: idFor(sessionId, callId), callId };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Missing or ambiguous evidence is resolved by a bounded follow-stream wait.
|
|
65
|
+
}
|
|
66
|
+
/** A follow snapshot can precede $events' pending-delivery replay after ready. */
|
|
67
|
+
export function expectedInteractionIds(sessionId, records) {
|
|
68
|
+
const { calls, approvals } = pending(records);
|
|
69
|
+
return [...new Set([
|
|
70
|
+
...[...approvals.values()].map(audit => idFor(sessionId, string(audit.callId) || string(audit.id))),
|
|
71
|
+
...[...calls.values()].filter(call => call.name === 'ask_user_question').map(call => idFor(sessionId, string(call.callId))),
|
|
72
|
+
])];
|
|
73
|
+
}
|