@khorsheed/dsh-client-message-tools 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +82 -0
  4. package/README.i18n.yaml +6 -0
  5. package/README.md +82 -0
  6. package/cordis.patch.yml +6 -0
  7. package/lib/client.js +5824 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +644 -0
  10. package/lib/invariant.js +27 -0
  11. package/lib/tsconfig.client.tsbuildinfo +1 -0
  12. package/lib/tsconfig.host.tsbuildinfo +1 -0
  13. package/lib/typert.host.d.ts +3 -0
  14. package/lib/typert.host.js +250 -0
  15. package/lib/typert.remote-client.d.ts +26 -0
  16. package/lib/typert.remote-client.d.ts.map +1 -0
  17. package/lib/typert.remote-client.js +154 -0
  18. package/lib/types/client/ModelChip.d.ts +29 -0
  19. package/lib/types/client/ModelChip.d.ts.map +1 -0
  20. package/lib/types/client/ModelChip.js +86 -0
  21. package/lib/types/client/RestoredMessageView.d.ts +17 -0
  22. package/lib/types/client/RestoredMessageView.d.ts.map +1 -0
  23. package/lib/types/client/RestoredMessageView.js +9 -0
  24. package/lib/types/client/UserMessageView.d.ts +16 -0
  25. package/lib/types/client/UserMessageView.d.ts.map +1 -0
  26. package/lib/types/client/UserMessageView.js +172 -0
  27. package/lib/types/client/WithdrawnDividerView.d.ts +17 -0
  28. package/lib/types/client/WithdrawnDividerView.d.ts.map +1 -0
  29. package/lib/types/client/WithdrawnDividerView.js +59 -0
  30. package/lib/types/client/backfill.d.ts +19 -0
  31. package/lib/types/client/backfill.d.ts.map +1 -0
  32. package/lib/types/client/backfill.js +20 -0
  33. package/lib/types/client/dom-hider.d.ts +61 -0
  34. package/lib/types/client/dom-hider.d.ts.map +1 -0
  35. package/lib/types/client/dom-hider.js +195 -0
  36. package/lib/types/client/edit-in-place.d.ts +107 -0
  37. package/lib/types/client/edit-in-place.d.ts.map +1 -0
  38. package/lib/types/client/edit-in-place.js +97 -0
  39. package/lib/types/client/icons.d.ts +23 -0
  40. package/lib/types/client/icons.d.ts.map +1 -0
  41. package/lib/types/client/icons.js +11 -0
  42. package/lib/types/client/index.d.ts +39 -0
  43. package/lib/types/client/index.d.ts.map +1 -0
  44. package/lib/types/client/index.js +187 -0
  45. package/lib/types/client/locales.d.ts +74 -0
  46. package/lib/types/client/locales.d.ts.map +1 -0
  47. package/lib/types/client/locales.js +65 -0
  48. package/lib/types/client/slots.d.ts +44 -0
  49. package/lib/types/client/slots.d.ts.map +1 -0
  50. package/lib/types/client/slots.js +1 -0
  51. package/lib/types/client/withdraw-backfill.d.ts +25 -0
  52. package/lib/types/client/withdraw-backfill.d.ts.map +1 -0
  53. package/lib/types/client/withdraw-backfill.js +21 -0
  54. package/lib/types/client/withdrawn-node.d.ts +163 -0
  55. package/lib/types/client/withdrawn-node.d.ts.map +1 -0
  56. package/lib/types/client/withdrawn-node.js +297 -0
  57. package/lib/types/index.d.ts +69 -0
  58. package/lib/types/index.d.ts.map +1 -0
  59. package/lib/types/index.js +175 -0
  60. package/lib/types/invariant.d.ts +16 -0
  61. package/lib/types/invariant.d.ts.map +1 -0
  62. package/lib/types/invariant.js +25 -0
  63. package/lib/types/marker.d.ts +94 -0
  64. package/lib/types/marker.d.ts.map +1 -0
  65. package/lib/types/marker.js +131 -0
  66. package/lib/types/types.d.ts +116 -0
  67. package/lib/types/types.d.ts.map +1 -0
  68. package/lib/types/types.js +1 -0
  69. package/lib/types/withdraw.d.ts +112 -0
  70. package/lib/types/withdraw.d.ts.map +1 -0
  71. package/lib/types/withdraw.js +279 -0
  72. package/package.json +108 -0
@@ -0,0 +1,112 @@
1
+ import type { SessionEvent } from '@deepseek-ai/dsh-session/types';
2
+ /** Why a withdrawal cannot be planned. */
3
+ export type WithdrawalPlanFailure = 'target-not-found' | 'not-a-user-message' | 'already-withdrawn';
4
+ /** The validated replacement intent for one withdrawal. */
5
+ export interface WithdrawalPlan {
6
+ /** Seq of the withdrawn user message (a current surface node). */
7
+ readonly start: number;
8
+ /** Seq of the last current surface node; the span replaces [start, end]. */
9
+ readonly end: number;
10
+ /** Every shadowed surface node, in surface order (surface validation requires the complete set). */
11
+ readonly sourceEventSeqs: readonly number[];
12
+ }
13
+ /** Planning outcome: a replacement intent or a failure code. */
14
+ export type WithdrawalPlanResult = {
15
+ readonly ok: true;
16
+ readonly plan: WithdrawalPlan;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly code: WithdrawalPlanFailure;
20
+ };
21
+ /**
22
+ * Plan a withdrawal: the target must be an editable user message (original,
23
+ * restore replay, or edit replacement — see `isEditableTarget`) still present
24
+ * on the live surface; the span covers it and every surface node after it.
25
+ * @param events - the full session log (seq === index).
26
+ * @param surfaceNodes - the current model-visible surface seqs, in order.
27
+ * @param targetSeq - seq of the user message to withdraw.
28
+ * @returns the replacement intent, or the failure code.
29
+ */
30
+ export declare function planWithdrawal(events: readonly SessionEvent[], surfaceNodes: readonly number[], targetSeq: number): WithdrawalPlanResult;
31
+ /** Why an edit cannot be planned. */
32
+ export type EditPlanFailure = 'target-not-found' | 'not-a-user-message' | 'already-withdrawn' | 'empty-text';
33
+ /** Planning outcome: a replacement intent or a failure code. */
34
+ export type EditPlanResult = {
35
+ readonly ok: true;
36
+ readonly plan: WithdrawalPlan;
37
+ } | {
38
+ readonly ok: false;
39
+ readonly code: EditPlanFailure;
40
+ };
41
+ /**
42
+ * Plan an edit: the target must be an editable user message still present on
43
+ * the live surface, and the new text must be non-blank. The span covers the
44
+ * target and every surface node after it — editing an old message discards
45
+ * what followed it. The replacement's content is the new text (no
46
+ * placeholder), so the model context reads the edited message in place.
47
+ * @param events - the full session log (seq === index).
48
+ * @param surfaceNodes - the current model-visible surface seqs, in order.
49
+ * @param targetSeq - seq of the user message to edit.
50
+ * @param text - the edited text.
51
+ * @returns the replacement intent, or the failure code.
52
+ */
53
+ export declare function planEdit(events: readonly SessionEvent[], surfaceNodes: readonly number[], targetSeq: number, text: string): EditPlanResult;
54
+ /** Why a restore cannot be planned. */
55
+ export type RestorePlanFailure = 'target-not-found' | 'not-a-user-message' | 'not-withdrawn';
56
+ /**
57
+ * One replayed entry of a restored span, in original order. User entries
58
+ * replay content blocks verbatim (originals, edit replacements — whose
59
+ * content IS the last edit's new text — and earlier restore replays);
60
+ * assistant entries carry the reply's joined text, framed for the model.
61
+ */
62
+ export type RestoreReplayEntry = {
63
+ readonly role: 'user';
64
+ /** The entry's content blocks, replayed verbatim. */
65
+ readonly content: SessionEvent<'user/message'>['data']['content'];
66
+ /** Seq of the original event this entry replays. */
67
+ readonly sourceSeq: number;
68
+ } | {
69
+ readonly role: 'assistant';
70
+ /** The model-facing text (frame included — see RESTORED_ASSISTANT_NOTICE). */
71
+ readonly text: string;
72
+ /** Seq of the original event this entry replays. */
73
+ readonly sourceSeq: number;
74
+ };
75
+ /** The validated restore intent for one withdrawn message. */
76
+ export interface RestorePlan {
77
+ /**
78
+ * Every replayable entry of the withdrawn span, in original (surface)
79
+ * order; the host appends them back-to-back at the tail. Never empty: the
80
+ * target message itself always replays. Tool calls/results never replay —
81
+ * the call/result pairing cannot be re-entered and their side effects are
82
+ * not replayable; the assistant text already summarizes them.
83
+ */
84
+ readonly entries: readonly RestoreReplayEntry[];
85
+ }
86
+ /** Restore planning outcome: a restore intent or a failure code. */
87
+ export type RestorePlanResult = {
88
+ readonly ok: true;
89
+ readonly plan: RestorePlan;
90
+ } | {
91
+ readonly ok: false;
92
+ readonly code: RestorePlanFailure;
93
+ };
94
+ /**
95
+ * Plan a restore: the target must be an editable user message (the same
96
+ * vocabulary as withdrawal — original, restore replay, or edit replacement)
97
+ * that is no longer on the live surface (i.e. withdrawn). Restoring an edit
98
+ * replacement replays the span's last edit text — the replacement's content
99
+ * IS the new text. The surface model is positional — a replaced span folds
100
+ * into exactly one node — so the withdrawn span can never be put back in
101
+ * place; restore replays the span's replayable content (user messages, the
102
+ * last edit's new text, assistant text) as fresh tail messages in original
103
+ * order. When no message-tools replacement cites the target (a foreign
104
+ * shadowing, e.g. compaction), only the target message replays — the span
105
+ * boundary is never re-guessed.
106
+ * @param events - the full session log (seq === index).
107
+ * @param surfaceNodes - the current model-visible surface seqs.
108
+ * @param targetSeq - seq of the withdrawn user message to restore.
109
+ * @returns the restore intent, or the failure code.
110
+ */
111
+ export declare function planRestore(events: readonly SessionEvent[], surfaceNodes: readonly number[], targetSeq: number): RestorePlanResult;
112
+ //# sourceMappingURL=withdraw.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"withdraw.d.ts","sourceRoot":"","sources":["../../src/withdraw.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Withdrawal planning: a pure fold from the session log and the live surface
3
+ * to the replacement intent. Kept free of cordis and Session internals so the
4
+ * unit tests drive it directly; the service only validates and appends.
5
+ * @module @khorsheed/dsh-client-message-tools/withdraw
6
+ */
7
+ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
8
+ import { RESTORED_ASSISTANT_NOTICE, isMessageToolsEdit, isMessageToolsReplacement, isMessageToolsRestore, isMessageToolsRestoreAssistant, } from './marker.js';
9
+ /**
10
+ * Whether a target seq names an editable user message: an append-surface
11
+ * user-source message (an original), a message-tools restore replay (a
12
+ * restored row withdraws and edits like the message it replays), or a
13
+ * message-tools edit replacement (edit chains — the previous edit's
14
+ * replacement is the surface node). Assistant-text restore replays carry no
15
+ * actions and are excluded.
16
+ */
17
+ function isEditableTarget(event) {
18
+ if (event === undefined || event.type !== 'user/message')
19
+ return false;
20
+ if (isAppendSurfaceEvent(event)) {
21
+ return event.data.source.kind === 'user' || isMessageToolsRestore(event);
22
+ }
23
+ return isMessageToolsEdit(event);
24
+ }
25
+ /** The span [target, surface tail] with full provenance, or undefined when off-surface. */
26
+ function surfaceSpan(surfaceNodes, targetSeq) {
27
+ const startIndex = surfaceNodes.indexOf(targetSeq);
28
+ if (startIndex === -1)
29
+ return undefined;
30
+ const sourceEventSeqs = surfaceNodes.slice(startIndex);
31
+ const end = sourceEventSeqs[sourceEventSeqs.length - 1];
32
+ /* v8 ignore next -- the slice is non-empty by construction (startIndex names a member) */
33
+ if (end === undefined)
34
+ return undefined;
35
+ return { start: targetSeq, end, sourceEventSeqs };
36
+ }
37
+ /**
38
+ * Plan a withdrawal: the target must be an editable user message (original,
39
+ * restore replay, or edit replacement — see `isEditableTarget`) still present
40
+ * on the live surface; the span covers it and every surface node after it.
41
+ * @param events - the full session log (seq === index).
42
+ * @param surfaceNodes - the current model-visible surface seqs, in order.
43
+ * @param targetSeq - seq of the user message to withdraw.
44
+ * @returns the replacement intent, or the failure code.
45
+ */
46
+ export function planWithdrawal(events, surfaceNodes, targetSeq) {
47
+ const target = events[targetSeq];
48
+ if (target === undefined)
49
+ return { ok: false, code: 'target-not-found' };
50
+ if (!isEditableTarget(target)) {
51
+ return { ok: false, code: 'not-a-user-message' };
52
+ }
53
+ const span = surfaceSpan(surfaceNodes, targetSeq);
54
+ if (span === undefined)
55
+ return { ok: false, code: 'already-withdrawn' };
56
+ return { ok: true, plan: span };
57
+ }
58
+ /**
59
+ * Plan an edit: the target must be an editable user message still present on
60
+ * the live surface, and the new text must be non-blank. The span covers the
61
+ * target and every surface node after it — editing an old message discards
62
+ * what followed it. The replacement's content is the new text (no
63
+ * placeholder), so the model context reads the edited message in place.
64
+ * @param events - the full session log (seq === index).
65
+ * @param surfaceNodes - the current model-visible surface seqs, in order.
66
+ * @param targetSeq - seq of the user message to edit.
67
+ * @param text - the edited text.
68
+ * @returns the replacement intent, or the failure code.
69
+ */
70
+ export function planEdit(events, surfaceNodes, targetSeq, text) {
71
+ if (text.trim() === '')
72
+ return { ok: false, code: 'empty-text' };
73
+ const target = events[targetSeq];
74
+ if (target === undefined)
75
+ return { ok: false, code: 'target-not-found' };
76
+ if (!isEditableTarget(target)) {
77
+ return { ok: false, code: 'not-a-user-message' };
78
+ }
79
+ const span = surfaceSpan(surfaceNodes, targetSeq);
80
+ if (span === undefined)
81
+ return { ok: false, code: 'already-withdrawn' };
82
+ return { ok: true, plan: span };
83
+ }
84
+ /** Join the text blocks of one message's content into one plain string. */
85
+ function joinText(blocks) {
86
+ return blocks
87
+ .filter((block) => block.type === 'text'
88
+ && typeof block.text === 'string')
89
+ .map(block => block.text)
90
+ .join('');
91
+ }
92
+ /**
93
+ * Join an assistant message's visible text. Normal replies use text blocks;
94
+ * interrupted replies may contain only reasoning. Prefer text when present,
95
+ * otherwise preserve the reasoning so a restore does not drop the only
96
+ * assistant content the user saw.
97
+ */
98
+ function joinAssistantMessageText(blocks) {
99
+ const text = joinText(blocks);
100
+ if (text !== '')
101
+ return text;
102
+ return blocks
103
+ .filter((block) => block.type === 'reasoning'
104
+ && typeof block.text === 'string')
105
+ .map(block => block.text)
106
+ .join('');
107
+ }
108
+ /**
109
+ * The full log seqs shadowed by the latest message-tools withdrawal
110
+ * replacement citing `targetSeq`, or undefined when no such replacement exists
111
+ * (the message left the surface through another producer, e.g. compaction).
112
+ *
113
+ * `sourceEventSeqs` is authoritative for the shadowed *surface* nodes, but an
114
+ * interrupted assistant step may exist only as `assistant/chunk` events (no
115
+ * `assistant/message` ever landed). Those chunks are not surface nodes, so
116
+ * they are absent from `sourceEventSeqs`. The replacement's own `start` and
117
+ * `seq` delimit the whole withdrawn log interval — `[start, seq)` — and are
118
+ * still read from the replacement itself, never guessed from another producer.
119
+ */
120
+ function findWithdrawnSpan(events, targetSeq) {
121
+ for (let index = events.length - 1; index > targetSeq; index--) {
122
+ const event = events[index];
123
+ if (event !== undefined && isMessageToolsReplacement(event) && event.sourceEventSeqs?.includes(targetSeq)) {
124
+ const span = [];
125
+ for (let seq = event.surfaceOp.start; seq < event.seq; seq++)
126
+ span.push(seq);
127
+ return span;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ /**
133
+ * Fold one withdrawn span into its replay entries, in span (original) order.
134
+ * Replayed: user-source messages, edit replacements (their content is the
135
+ * last edit's new text), earlier restore replays, and assistant text (framed;
136
+ * restore-assistant replays already carry the frame). Skipped: withdrawal
137
+ * placeholders, edit triggers, other plugin context, and tool calls/results.
138
+ *
139
+ * Assistant text normally comes from `assistant/message` surface events. A
140
+ * turn that was interrupted before finalization may never emit
141
+ * `assistant/message`; its visible content survives only as `assistant/chunk`
142
+ * rows. Those chunks are folded into one assistant replay per step when no
143
+ * final `assistant/message` exists for that step, so withdrawing/restoring an
144
+ * interrupted reply does not lose the partial assistant content.
145
+ */
146
+ function replayEntries(events, spanSeqs) {
147
+ const entries = [];
148
+ let pending;
149
+ const finalizedSteps = new Set();
150
+ const flushPending = () => {
151
+ if (pending === undefined)
152
+ return;
153
+ if (!pending.finalized) {
154
+ const text = [...pending.text.values()].join('');
155
+ const reasoning = [...pending.reasoning.values()].join('');
156
+ const replay = text !== '' ? text : reasoning;
157
+ if (replay !== '') {
158
+ entries.push({
159
+ role: 'assistant',
160
+ text: `${RESTORED_ASSISTANT_NOTICE}\n${replay}`,
161
+ sourceSeq: pending.sourceSeq,
162
+ });
163
+ }
164
+ }
165
+ pending = undefined;
166
+ };
167
+ const pushChunk = (event) => {
168
+ if (finalizedSteps.has(`${event.data.turn}:${event.data.step}`))
169
+ return;
170
+ if (pending === undefined
171
+ || pending.turn !== event.data.turn
172
+ || pending.step !== event.data.step) {
173
+ flushPending();
174
+ pending = {
175
+ turn: event.data.turn,
176
+ step: event.data.step,
177
+ sourceSeq: event.seq,
178
+ text: new Map(),
179
+ reasoning: new Map(),
180
+ finalized: false,
181
+ };
182
+ }
183
+ const chunk = event.data.chunk;
184
+ if (chunk.type === 'text-delta') {
185
+ pending.text.set(chunk.index, (pending.text.get(chunk.index) ?? '') + chunk.text);
186
+ }
187
+ else if (chunk.type === 'block-end' && chunk.block.type === 'text') {
188
+ pending.text.set(chunk.index, chunk.block.text);
189
+ }
190
+ else if (chunk.type === 'reasoning-delta') {
191
+ pending.reasoning.set(chunk.index, (pending.reasoning.get(chunk.index) ?? '') + chunk.text);
192
+ }
193
+ else if (chunk.type === 'block-end' && chunk.block.type === 'reasoning') {
194
+ pending.reasoning.set(chunk.index, chunk.block.text);
195
+ }
196
+ };
197
+ const pushAssistantText = (text, sourceSeq) => {
198
+ if (text !== '') {
199
+ entries.push({ role: 'assistant', text: `${RESTORED_ASSISTANT_NOTICE}\n${text}`, sourceSeq });
200
+ }
201
+ };
202
+ for (const seq of spanSeqs) {
203
+ const event = events[seq];
204
+ if (event === undefined)
205
+ continue;
206
+ if (isMessageToolsEdit(event)) {
207
+ flushPending();
208
+ entries.push({ role: 'user', content: event.data.content, sourceSeq: seq });
209
+ continue;
210
+ }
211
+ if (event.type === 'assistant/chunk') {
212
+ pushChunk(event);
213
+ continue;
214
+ }
215
+ if (event.type === 'user/message') {
216
+ flushPending();
217
+ if (event.surfaceOp !== 'append')
218
+ continue;
219
+ if (event.data.source.kind === 'user' || isMessageToolsRestore(event)) {
220
+ entries.push({ role: 'user', content: event.data.content, sourceSeq: seq });
221
+ }
222
+ else if (isMessageToolsRestoreAssistant(event)) {
223
+ // Restore-assistant replays already carry the model-facing frame; do
224
+ // not add a second one.
225
+ entries.push({ role: 'assistant', text: joinText(event.data.content), sourceSeq: seq });
226
+ }
227
+ continue;
228
+ }
229
+ if (event.type === 'assistant/message') {
230
+ // A finalized step supersedes any pending chunk accumulation for the
231
+ // same step: flush without emitting the partial, then replay the final.
232
+ finalizedSteps.add(`${event.data.turn}:${event.data.step}`);
233
+ if (pending !== undefined
234
+ && pending.turn === event.data.turn
235
+ && pending.step === event.data.step) {
236
+ pending.finalized = true;
237
+ }
238
+ flushPending();
239
+ pushAssistantText(joinAssistantMessageText(event.data.message.content), seq);
240
+ continue;
241
+ }
242
+ // Log-only events (boundaries, headers, titles) do not split a chunk run;
243
+ // the pending assistant step is flushed at the next replayable boundary or
244
+ // when a different step starts.
245
+ }
246
+ flushPending();
247
+ return entries;
248
+ }
249
+ /**
250
+ * Plan a restore: the target must be an editable user message (the same
251
+ * vocabulary as withdrawal — original, restore replay, or edit replacement)
252
+ * that is no longer on the live surface (i.e. withdrawn). Restoring an edit
253
+ * replacement replays the span's last edit text — the replacement's content
254
+ * IS the new text. The surface model is positional — a replaced span folds
255
+ * into exactly one node — so the withdrawn span can never be put back in
256
+ * place; restore replays the span's replayable content (user messages, the
257
+ * last edit's new text, assistant text) as fresh tail messages in original
258
+ * order. When no message-tools replacement cites the target (a foreign
259
+ * shadowing, e.g. compaction), only the target message replays — the span
260
+ * boundary is never re-guessed.
261
+ * @param events - the full session log (seq === index).
262
+ * @param surfaceNodes - the current model-visible surface seqs.
263
+ * @param targetSeq - seq of the withdrawn user message to restore.
264
+ * @returns the restore intent, or the failure code.
265
+ */
266
+ export function planRestore(events, surfaceNodes, targetSeq) {
267
+ const target = events[targetSeq];
268
+ if (target === undefined)
269
+ return { ok: false, code: 'target-not-found' };
270
+ if (!isEditableTarget(target))
271
+ return { ok: false, code: 'not-a-user-message' };
272
+ if (surfaceNodes.includes(targetSeq))
273
+ return { ok: false, code: 'not-withdrawn' };
274
+ const span = findWithdrawnSpan(events, targetSeq);
275
+ const entries = span === undefined
276
+ ? [{ role: 'user', content: target.data.content, sourceSeq: targetSeq }]
277
+ : replayEntries(events, span);
278
+ return { ok: true, plan: { entries } };
279
+ }
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@khorsheed/dsh-client-message-tools",
3
+ "description": "User-message edit (withdraw and resend) and withdrawal: the withdrawal writes a surface replacement that hides the message and everything after it from the model, with a chat divider marking the spot",
4
+ "keywords": [
5
+ "dsh",
6
+ "dsh-plugin",
7
+ "cordis",
8
+ "messages"
9
+ ],
10
+ "version": "0.1.0",
11
+ "type": "module",
12
+ "main": "lib/index.js",
13
+ "types": "lib/types/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./lib/types/index.d.ts",
17
+ "default": "./lib/index.js"
18
+ },
19
+ "./invariant": {
20
+ "types": "./lib/types/invariant.d.ts",
21
+ "default": "./lib/invariant.js"
22
+ },
23
+ "./client": {
24
+ "types": "./lib/types/client/index.d.ts",
25
+ "default": "./lib/client.js"
26
+ },
27
+ "./types": {
28
+ "types": "./lib/types/types.d.ts",
29
+ "default": "./lib/types/types.js"
30
+ },
31
+ "./typert": {
32
+ "types": "./lib/typert.host.d.ts",
33
+ "default": "./lib/typert.host.js"
34
+ },
35
+ "./remote": {
36
+ "types": "./lib/typert.remote-client.d.ts",
37
+ "default": "./lib/typert.remote-client.js"
38
+ },
39
+ "./src/*": "./src/*",
40
+ "./package.json": "./package.json"
41
+ },
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-runtime",
49
+ "@deepseek-ai/dsh-api-remotes",
50
+ "@deepseek-ai/dsh-client-locale",
51
+ "@deepseek-ai/dsh-client-ui-conversation",
52
+ "@deepseek-ai/dsh-client-ui-model-selection"
53
+ ],
54
+ "platform": "web"
55
+ },
56
+ "compat": {
57
+ "minHost": "0.1.0-rc.8",
58
+ "verifiedHost": "0.1.1-rc.2"
59
+ }
60
+ },
61
+ "license": "MIT",
62
+ "peerDependencies": {
63
+ "@deepseek-ai/cordis": "^4.0.1",
64
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
65
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
66
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
67
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
68
+ "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
69
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
70
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
71
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
75
+ "react": "^18.2.0"
76
+ },
77
+ "devDependencies": {
78
+ "@deepseek-ai/cordis": "^4.0.1",
79
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.1",
80
+ "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.1",
81
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.1",
82
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.1",
83
+ "@deepseek-ai/dsh-client-test-runtime": "^0.1.1-rc.1",
84
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.1",
85
+ "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.1-rc.1",
86
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.1",
87
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.1",
88
+ "@deepseek-ai/dsh-invariants": "^0.1.1-rc.1",
89
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.1",
90
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.1",
91
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.1",
92
+ "@testing-library/react": "^16.1.0",
93
+ "@types/node": "^26.2.0",
94
+ "@types/react": "~18.3.1",
95
+ "react": "^18.2.0",
96
+ "react-dom": "^18.2.0"
97
+ },
98
+ "files": [
99
+ "lib",
100
+ "cordis.patch.yml",
101
+ "lib/typert.host.d.ts",
102
+ "lib/typert.host.js",
103
+ "lib/typert.remote-client.d.ts",
104
+ "lib/typert.remote-client.js",
105
+ "LICENSE",
106
+ "CHANGELOG.md"
107
+ ]
108
+ }