@khorsheed/dsh-client-message-tools 0.3.0 → 0.3.2

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.
@@ -33,7 +33,7 @@ export const withdrawnDividerDefinition = {
33
33
  },
34
34
  start: (_context, match) => {
35
35
  const event = match.event;
36
- if (!isMessageToolsReplacement(event)) {
36
+ if (event.type !== 'user/message' || !isMessageToolsReplacement(event)) {
37
37
  throw new Error('message-tools-withdrawn start requires a message-tools replacement event');
38
38
  }
39
39
  return { seq: event.seq, hiddenStartSeq: event.surfaceOp.startSeq };
@@ -59,7 +59,7 @@ export const editedMessageDefinition = {
59
59
  },
60
60
  start: (_context, match) => {
61
61
  const event = match.event;
62
- if (!isMessageToolsEdit(event)) {
62
+ if (event.type !== 'user/message' || !isMessageToolsEdit(event)) {
63
63
  throw new Error('message-tools-edited start requires a message-tools edit replacement event');
64
64
  }
65
65
  return {
@@ -94,6 +94,21 @@ export function foldHiddenRanges(nodes) {
94
94
  pairs.sort((left, right) => left[0] - right[0]);
95
95
  return pairs.flat();
96
96
  }
97
+ /**
98
+ * Fold one message's content blocks into the gallery slot's image input, in
99
+ * source order. Only `type: 'image'` blocks with an attachment qualify; a
100
+ * withdrawn user message must keep its images visible in the divider replay
101
+ * (they are exactly what the withdrawal hid).
102
+ */
103
+ function imageSources(blocks) {
104
+ const images = [];
105
+ for (const block of blocks) {
106
+ const b = block;
107
+ if (b.type === 'image' && b.attachment !== undefined)
108
+ images.push({ attachment: b.attachment });
109
+ }
110
+ return images;
111
+ }
97
112
  /** Join text blocks into one plain string. */
98
113
  function joinText(blocks) {
99
114
  return blocks
@@ -121,7 +136,7 @@ function joinAssistantText(blocks) {
121
136
  }
122
137
  /**
123
138
  * The restore-row Definition: one single-event Context per message-tools
124
- * user-message restore event (append-surface, plugin-tagged), replaying the
139
+ * user-message restore event (append-surface, producer-tagged), replaying the
125
140
  * withdrawn message's content at the tail.
126
141
  */
127
142
  export const restoredMessageDefinition = {
@@ -134,7 +149,7 @@ export const restoredMessageDefinition = {
134
149
  },
135
150
  start: (_context, match) => {
136
151
  const event = match.event;
137
- if (!isMessageToolsRestore(event)) {
152
+ if (event.type !== 'user/message' || !isMessageToolsRestore(event)) {
138
153
  throw new Error('message-tools-restored start requires a message-tools restore event');
139
154
  }
140
155
  return {
@@ -167,7 +182,7 @@ export const restoredAssistantMessageDefinition = {
167
182
  },
168
183
  start: (_context, match) => {
169
184
  const event = match.event;
170
- if (!isMessageToolsRestoreAssistant(event)) {
185
+ if (event.type !== 'user/message' || !isMessageToolsRestoreAssistant(event)) {
171
186
  throw new Error('message-tools-restored-assistant start requires a restore-assistant replay event');
172
187
  }
173
188
  return {
@@ -186,7 +201,7 @@ export const restoredAssistantMessageDefinition = {
186
201
  * Count the user-visible messages hidden by one withdrawn span. This mirrors
187
202
  * {@link collectWithdrawnEntries}: context injections, tool calls, turn tails,
188
203
  * and other UI chrome are not counted as “messages”, while restored/edited
189
- * rows and reasoning-only assistant steps are.
204
+ * rows, reasoning-only assistant steps, and image-only user messages are.
190
205
  * @param nodes - every materialized chat node.
191
206
  * @param startSeq - first hidden seq (inclusive).
192
207
  * @param endSeq - the divider's seq (exclusive).
@@ -196,12 +211,12 @@ export function countHiddenInSpan(nodes, startSeq, endSeq) {
196
211
  return collectWithdrawnEntries(nodes, startSeq, endSeq).length;
197
212
  }
198
213
  /**
199
- * Collect the read-only replay of one withdrawn span: user originals and
200
- * assistant text, in anchor order. Other kinds carry no user-readable text
201
- * and are skipped. Assistant-step data is kind-keyed (`data.blocks`, with
202
- * `data.finalNode.blocks` once settled — `conversation-nodes/assistant.ts`),
203
- * not type-keyed like message content. Reads the live node store — call from
204
- * event handlers.
214
+ * Collect the read-only replay of one withdrawn span: user originals (text
215
+ * and attached images alike) and assistant text, in anchor order. Other kinds
216
+ * carry no user-readable content and are skipped. Assistant-step data is
217
+ * kind-keyed (`data.blocks`, with `data.finalNode.blocks` once settled —
218
+ * `conversation-nodes/assistant.ts`), not type-keyed like message content.
219
+ * Reads the live node store — call from event handlers.
205
220
  * @param nodes - every materialized chat node.
206
221
  * @param startSeq - first hidden seq (inclusive).
207
222
  * @param endSeq - the divider's seq (exclusive).
@@ -216,27 +231,30 @@ export function collectWithdrawnEntries(nodes, startSeq, endSeq) {
216
231
  if (node.kind === 'user' || node.kind === 'steering' || node.kind === 'message-tools-edited') {
217
232
  const content = node.data.content ?? [];
218
233
  const text = joinText(content);
219
- if (text !== '')
220
- entries.push({ kind: 'user', text });
234
+ const images = imageSources(content);
235
+ if (text !== '' || images.length > 0)
236
+ entries.push({ kind: 'user', text, images });
221
237
  continue;
222
238
  }
223
239
  if (node.kind === 'message-tools-restored') {
224
- const text = node.data.text ?? '';
225
- if (text !== '')
226
- entries.push({ kind: 'user', text });
240
+ const data = node.data;
241
+ const text = data.text ?? '';
242
+ const images = imageSources(data.content ?? []);
243
+ if (text !== '' || images.length > 0)
244
+ entries.push({ kind: 'user', text, images });
227
245
  continue;
228
246
  }
229
247
  if (node.kind === 'message-tools-restored-assistant') {
230
248
  const text = node.data.text ?? '';
231
249
  if (text !== '')
232
- entries.push({ kind: 'assistant', text });
250
+ entries.push({ kind: 'assistant', text, images: [] });
233
251
  continue;
234
252
  }
235
253
  if (node.kind === 'assistant-step') {
236
254
  const data = node.data;
237
255
  const text = joinAssistantText(data.finalNode?.blocks ?? data.blocks ?? []);
238
256
  if (text !== '')
239
- entries.push({ kind: 'assistant', text });
257
+ entries.push({ kind: 'assistant', text, images: [] });
240
258
  }
241
259
  }
242
260
  return entries;
@@ -21,7 +21,7 @@ declare module '@deepseek-ai/cordis' {
21
21
  /**
22
22
  * messageTools Remote service: appends the withdrawal replacement to the
23
23
  * owning Session and flushes it durable. The replacement event's
24
- * plugin-tagged source is the audit trail; no new session event type is
24
+ * producer-owned source is the audit trail; no new session event type is
25
25
  * introduced, so session reload stays safe for harnesses without this plugin.
26
26
  */
27
27
  export declare class MessageToolsService extends TypertRemoteService {
@@ -32,7 +32,7 @@ export declare class MessageToolsService extends TypertRemoteService {
32
32
  constructor(ctx: Context);
33
33
  /**
34
34
  * Withdraw a user message: replace it and every surface node after it with
35
- * a minimal plugin-sourced placeholder, hiding the span from the model.
35
+ * a minimal producer-sourced placeholder, hiding the span from the model.
36
36
  * @param request - session identity and target message seq.
37
37
  * @returns the replacement receipt, or a rejection from the closed failure union.
38
38
  */
@@ -40,14 +40,14 @@ export declare class MessageToolsService extends TypertRemoteService {
40
40
  /**
41
41
  * Restore a withdrawn span: replay every replayable entry of it (user
42
42
  * messages verbatim — including the last edit's new text — and framed
43
- * assistant text; tool calls/results never replay) as fresh plugin-tagged
44
- * `user/message` appends at the conversation tail, in original order, each
45
- * citing its original event in `sourceEventSeqs`. The surface replace model
46
- * is positional — a replaced span folds into one node — so the span cannot
47
- * be restored in place; the replayed entries are simply the newest messages
48
- * the model sees. Assistant text replays as framed user-role messages:
49
- * `assistant/message` cannot carry a plugin source and the turn/step trace
50
- * forbids assistant appends outside a step.
43
+ * assistant text; tool calls/results never replay) as fresh
44
+ * producer-sourced `user/message` appends at the conversation tail, in
45
+ * original order, each citing its original event in `sourceEventSeqs`. The
46
+ * surface replace model is positional — a replaced span folds into one
47
+ * node — so the span cannot be restored in place; the replayed entries are
48
+ * simply the newest messages the model sees. Assistant text replays as
49
+ * framed user-role messages: `assistant/message` requires the model source
50
+ * and the turn/step trace forbids assistant appends outside a step.
51
51
  * @param request - session identity and withdrawn message seq.
52
52
  * @returns the restore receipt, or a rejection from the closed failure union.
53
53
  */
@@ -57,7 +57,7 @@ export declare class MessageToolsService extends TypertRemoteService {
57
57
  * with the edited text itself (no placeholder, no tail duplicate — the
58
58
  * edited text appears exactly once, in the replacement event), then start a
59
59
  * regeneration turn. The harness has no wake-without-append path, so the
60
- * turn is started by `agent.followup` carrying a minimal plugin-sourced
60
+ * turn is started by `agent.followup` carrying a minimal producer-sourced
61
61
  * trigger message (the ankh-guard/schedule pattern); the trigger is not the
62
62
  * edited text and lands once, as itself.
63
63
  * @param request - session identity, target seq, edited text.
@@ -36,12 +36,13 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
36
36
  import { SessionSeq } from '@deepseek-ai/dsh-session';
37
37
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
38
38
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
39
- import { EDIT_TRIGGER_NOTICE, MESSAGE_TOOLS_PLUGIN, WITHDRAWN_NOTICE, editReplacementSource, editTriggerSource, restoreAssistantSource, } from './marker.js';
39
+ import { EDIT_TRIGGER_NOTICE, WITHDRAWN_NOTICE, editReplacementSource, editTriggerSource, messageToolsSource, restoreAssistantSource, } from './marker.js';
40
+ import { registerRestoreProjection } from './restore-projection.js';
40
41
  import { planEdit, planRestore, planWithdrawal } from './withdraw.js';
41
42
  /**
42
43
  * messageTools Remote service: appends the withdrawal replacement to the
43
44
  * owning Session and flushes it durable. The replacement event's
44
- * plugin-tagged source is the audit trail; no new session event type is
45
+ * producer-owned source is the audit trail; no new session event type is
45
46
  * introduced, so session reload stays safe for harnesses without this plugin.
46
47
  */
47
48
  let MessageToolsService = (() => {
@@ -68,10 +69,14 @@ let MessageToolsService = (() => {
68
69
  constructor(ctx) {
69
70
  super(ctx, 'messageTools');
70
71
  __runInitializers(this, _instanceExtraInitializers);
72
+ // Seam registry S12: on a host whose session fold can carry a user/message
73
+ // projection, restore replays derive assistant-role without the frame;
74
+ // every degrade path leaves the framed user-role channel verbatim.
75
+ registerRestoreProjection(ctx);
71
76
  }
72
77
  /**
73
78
  * Withdraw a user message: replace it and every surface node after it with
74
- * a minimal plugin-sourced placeholder, hiding the span from the model.
79
+ * a minimal producer-sourced placeholder, hiding the span from the model.
75
80
  * @param request - session identity and target message seq.
76
81
  * @returns the replacement receipt, or a rejection from the closed failure union.
77
82
  */
@@ -84,7 +89,7 @@ let MessageToolsService = (() => {
84
89
  return { ok: false, error: { code: planned.code } };
85
90
  const replacement = session.append('user/message', createUserMessage({
86
91
  content: [{ type: 'text', text: WITHDRAWN_NOTICE }],
87
- source: { kind: 'plugin', plugin: MESSAGE_TOOLS_PLUGIN },
92
+ source: messageToolsSource(),
88
93
  }), {
89
94
  surfaceOp: { op: 'replace', startSeq: SessionSeq(planned.plan.start), endSeq: SessionSeq(planned.plan.end) },
90
95
  sourceEventSeqs: planned.plan.sourceEventSeqs.map(seq => SessionSeq(seq)),
@@ -98,14 +103,14 @@ let MessageToolsService = (() => {
98
103
  /**
99
104
  * Restore a withdrawn span: replay every replayable entry of it (user
100
105
  * messages verbatim — including the last edit's new text — and framed
101
- * assistant text; tool calls/results never replay) as fresh plugin-tagged
102
- * `user/message` appends at the conversation tail, in original order, each
103
- * citing its original event in `sourceEventSeqs`. The surface replace model
104
- * is positional — a replaced span folds into one node — so the span cannot
105
- * be restored in place; the replayed entries are simply the newest messages
106
- * the model sees. Assistant text replays as framed user-role messages:
107
- * `assistant/message` cannot carry a plugin source and the turn/step trace
108
- * forbids assistant appends outside a step.
106
+ * assistant text; tool calls/results never replay) as fresh
107
+ * producer-sourced `user/message` appends at the conversation tail, in
108
+ * original order, each citing its original event in `sourceEventSeqs`. The
109
+ * surface replace model is positional — a replaced span folds into one
110
+ * node — so the span cannot be restored in place; the replayed entries are
111
+ * simply the newest messages the model sees. Assistant text replays as
112
+ * framed user-role messages: `assistant/message` requires the model source
113
+ * and the turn/step trace forbids assistant appends outside a step.
109
114
  * @param request - session identity and withdrawn message seq.
110
115
  * @returns the restore receipt, or a rejection from the closed failure union.
111
116
  */
@@ -121,7 +126,7 @@ let MessageToolsService = (() => {
121
126
  const message = entry.role === 'user'
122
127
  ? createUserMessage({
123
128
  content: [...entry.content],
124
- source: { kind: 'plugin', plugin: MESSAGE_TOOLS_PLUGIN },
129
+ source: messageToolsSource(),
125
130
  })
126
131
  : createUserMessage({
127
132
  content: [{ type: 'text', text: entry.text }],
@@ -141,7 +146,7 @@ let MessageToolsService = (() => {
141
146
  * with the edited text itself (no placeholder, no tail duplicate — the
142
147
  * edited text appears exactly once, in the replacement event), then start a
143
148
  * regeneration turn. The harness has no wake-without-append path, so the
144
- * turn is started by `agent.followup` carrying a minimal plugin-sourced
149
+ * turn is started by `agent.followup` carrying a minimal producer-sourced
145
150
  * trigger message (the ankh-guard/schedule pattern); the trigger is not the
146
151
  * edited text and lands once, as itself.
147
152
  * @param request - session identity, target seq, edited text.
@@ -1,7 +1,23 @@
1
- import type { MessageSource } from '@deepseek-ai/dsh-llm/message';
1
+ import type { ContextFormed, MessageSource } from '@deepseek-ai/dsh-llm/message';
2
2
  import type { SessionEvent } from '@deepseek-ai/dsh-session/types';
3
- /** Plugin source tag identifying every message-tools event. */
3
+ /**
4
+ * The producer-owned source kind identifying every message-tools event. It is
5
+ * also the legacy wrapper's `plugin` value: released V3 logs carry
6
+ * `{ kind: 'plugin', plugin: 'message-tools' }` (a pre-V4 host still serves
7
+ * them verbatim), and the official V3→V4 migration rewrites those to
8
+ * `kind: 'plugin:message-tools'` — {@link isMessageToolsSource} accepts all
9
+ * three durable forms.
10
+ */
4
11
  export declare const MESSAGE_TOOLS_PLUGIN = "message-tools";
12
+ declare module '@deepseek-ai/dsh-llm' {
13
+ interface MessageSourceMap {
14
+ /** message-tools producer: withdrawal/edit replacements, edit triggers, restore replays. */
15
+ 'message-tools': {
16
+ kind: 'message-tools';
17
+ readonly op?: MessageToolsOp;
18
+ } & ContextFormed;
19
+ }
20
+ }
5
21
  /**
6
22
  * Model-facing placeholder left on the surface in place of the withdrawn
7
23
  * span. The span itself is hidden from the model; this minimal user message
@@ -17,21 +33,36 @@ export declare const WITHDRAWN_NOTICE = "(\u7528\u6237\u64A4\u56DE\u4E86\u8FD9\u
17
33
  export declare const EDIT_TRIGGER_NOTICE = "(\u7528\u6237\u7F16\u8F91\u4E86\u4E0A\u4E00\u6761\u6D88\u606F\uFF0C\u8BF7\u6309\u7F16\u8F91\u540E\u7684\u5185\u5BB9\u91CD\u65B0\u56DE\u7B54)";
18
34
  /**
19
35
  * Model-facing frame preceding a replayed assistant text in a restore: the
20
- * replay lands as a user-role message (assistant/message cannot carry a
21
- * plugin source, and the session turn/step trace forbids ad-hoc assistant
22
- * appends), so the frame keeps the role honest for the model.
36
+ * replay lands as a user-role message (assistant/message requires the model
37
+ * source, and the session turn/step trace forbids ad-hoc assistant
38
+ * appends), so the frame keeps the role honest for the model. On hosts whose
39
+ * session fold carries the S12 `user/message` projection the frame never
40
+ * reaches the model — the projection strips it and corrects the role at
41
+ * derivation time; everywhere else it is the role-honesty device.
23
42
  */
24
43
  export declare const RESTORED_ASSISTANT_NOTICE = "(\u4EE5\u4E0B\u662F\u5148\u524D\u88AB\u64A4\u56DE\u3001\u73B0\u968F\u6062\u590D\u653E\u56DE\u7684\u52A9\u624B\u56DE\u590D)";
25
- /** The `op` discriminator on a message-tools plugin source. */
44
+ /** The `op` discriminator on a message-tools source. */
26
45
  export type MessageToolsOp = 'edit' | 'edit-trigger' | 'restore-assistant';
27
- /** The plugin-tagged source of an edit replacement (op: 'edit'). */
46
+ /** The producer-owned source of a withdrawal replacement or a user-message restore replay (no op). */
47
+ export declare function messageToolsSource(): MessageSource;
48
+ /** The producer-owned source of an edit replacement (op: 'edit'). */
28
49
  export declare function editReplacementSource(): MessageSource;
29
- /** The plugin-tagged source of an edit trigger message (op: 'edit-trigger'). */
50
+ /** The producer-owned source of an edit trigger message (op: 'edit-trigger'). */
30
51
  export declare function editTriggerSource(): MessageSource;
31
- /** The plugin-tagged source of a restored assistant-text replay (op: 'restore-assistant'). */
52
+ /** The producer-owned source of a restored assistant-text replay (op: 'restore-assistant'). */
32
53
  export declare function restoreAssistantSource(): MessageSource;
33
- /** Read the op discriminator off a message source (absent on legacy events). */
54
+ /** Read the op discriminator off a message source (absent on plain withdrawal/restore events). */
34
55
  export declare function messageToolsOp(source: unknown): MessageToolsOp | undefined;
56
+ /**
57
+ * Whether a durable source belongs to message-tools, in every form the log
58
+ * can hold: the current producer-owned kind (`message-tools`), the V3→V4
59
+ * migrated kind (`plugin:message-tools` — released V3 files rewritten once by
60
+ * the official migration), and the released V3 wrapper a pre-V4 host still
61
+ * serves verbatim (`{ kind: 'plugin', plugin: 'message-tools' }`).
62
+ * @param source - the persisted message source to test.
63
+ * @returns true when the source names this producer.
64
+ */
65
+ export declare function isMessageToolsSource(source: unknown): boolean;
35
66
  /**
36
67
  * Strip the model-facing restore frame from a replayed assistant text for
37
68
  * DISPLAY: the frame belongs to the model context (role honesty), never to
@@ -56,8 +87,9 @@ export type MessageToolsRestoreEvent = SessionEvent<'user/message'> & {
56
87
  };
57
88
  /**
58
89
  * Whether the event is a message-tools WITHDRAWAL replacement: a
59
- * `user/message` surface replacement whose source carries this plugin's tag
60
- * and no `op` discriminator (legacy and current withdrawals both).
90
+ * `user/message` surface replacement whose source names this producer
91
+ * (any durable form — see {@link isMessageToolsSource}) with no `op`
92
+ * discriminator.
61
93
  * @param event - the session event to test.
62
94
  * @returns the event narrowed to a withdrawal replacement when matched.
63
95
  */
@@ -78,7 +110,7 @@ export declare function isMessageToolsEdit(event: SessionEvent): event is Messag
78
110
  export declare function isMessageToolsRestore(event: SessionEvent): event is MessageToolsRestoreEvent;
79
111
  /**
80
112
  * Whether the event is a message-tools edit trigger: the minimal
81
- * plugin-sourced message that starts the regeneration turn after an edit.
113
+ * producer-sourced message that starts the regeneration turn after an edit.
82
114
  * @param event - the session event to test.
83
115
  * @returns the event narrowed to an edit trigger when matched.
84
116
  */
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Shared message-tools markers: the plugin source tag stamped on withdrawal
3
- * replacement events, the `op` discriminator telling edit replacements, edit
4
- * triggers, and assistant-text restore replays apart from withdrawals and
5
- * user-message restores, the model-facing placeholder and trigger texts, and
6
- * the pure predicates both the Host service and the browser projection
7
- * consume. No new session event type is ever written — an
2
+ * Shared message-tools markers: the producer-owned source kind stamped on
3
+ * every message-tools event, the `op` discriminator telling edit
4
+ * replacements, edit triggers, and assistant-text restore replays apart from
5
+ * withdrawals and user-message restores, the model-facing placeholder and
6
+ * trigger texts, and the pure predicates both the Host service and the
7
+ * browser projection consume. No new session event type is ever written — an
8
8
  * out-of-harness event type cannot carry `ignorable: true` (Session.append
9
9
  * assigns the envelope), so a persisted unknown type would make
10
10
  * session-persistence refuse the log on reload. The events themselves are the
@@ -12,7 +12,14 @@
12
12
  * @module @khorsheed/dsh-client-message-tools/marker
13
13
  */
14
14
  import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
15
- /** Plugin source tag identifying every message-tools event. */
15
+ /**
16
+ * The producer-owned source kind identifying every message-tools event. It is
17
+ * also the legacy wrapper's `plugin` value: released V3 logs carry
18
+ * `{ kind: 'plugin', plugin: 'message-tools' }` (a pre-V4 host still serves
19
+ * them verbatim), and the official V3→V4 migration rewrites those to
20
+ * `kind: 'plugin:message-tools'` — {@link isMessageToolsSource} accepts all
21
+ * three durable forms.
22
+ */
16
23
  export const MESSAGE_TOOLS_PLUGIN = 'message-tools';
17
24
  /**
18
25
  * Model-facing placeholder left on the surface in place of the withdrawn
@@ -29,28 +36,52 @@ export const WITHDRAWN_NOTICE = '(用户撤回了这条消息及其后的所有
29
36
  export const EDIT_TRIGGER_NOTICE = '(用户编辑了上一条消息,请按编辑后的内容重新回答)';
30
37
  /**
31
38
  * Model-facing frame preceding a replayed assistant text in a restore: the
32
- * replay lands as a user-role message (assistant/message cannot carry a
33
- * plugin source, and the session turn/step trace forbids ad-hoc assistant
34
- * appends), so the frame keeps the role honest for the model.
39
+ * replay lands as a user-role message (assistant/message requires the model
40
+ * source, and the session turn/step trace forbids ad-hoc assistant
41
+ * appends), so the frame keeps the role honest for the model. On hosts whose
42
+ * session fold carries the S12 `user/message` projection the frame never
43
+ * reaches the model — the projection strips it and corrects the role at
44
+ * derivation time; everywhere else it is the role-honesty device.
35
45
  */
36
46
  export const RESTORED_ASSISTANT_NOTICE = '(以下是先前被撤回、现随恢复放回的助手回复)';
37
- /** The plugin-tagged source of an edit replacement (op: 'edit'). */
47
+ /** The producer-owned source of a withdrawal replacement or a user-message restore replay (no op). */
48
+ export function messageToolsSource() {
49
+ return Object.freeze({ kind: MESSAGE_TOOLS_PLUGIN });
50
+ }
51
+ /** The producer-owned source of an edit replacement (op: 'edit'). */
38
52
  export function editReplacementSource() {
39
- return Object.freeze({ kind: 'plugin', plugin: MESSAGE_TOOLS_PLUGIN, op: 'edit' });
53
+ return Object.freeze({ kind: MESSAGE_TOOLS_PLUGIN, op: 'edit' });
40
54
  }
41
- /** The plugin-tagged source of an edit trigger message (op: 'edit-trigger'). */
55
+ /** The producer-owned source of an edit trigger message (op: 'edit-trigger'). */
42
56
  export function editTriggerSource() {
43
- return Object.freeze({ kind: 'plugin', plugin: MESSAGE_TOOLS_PLUGIN, op: 'edit-trigger' });
57
+ return Object.freeze({ kind: MESSAGE_TOOLS_PLUGIN, op: 'edit-trigger' });
44
58
  }
45
- /** The plugin-tagged source of a restored assistant-text replay (op: 'restore-assistant'). */
59
+ /** The producer-owned source of a restored assistant-text replay (op: 'restore-assistant'). */
46
60
  export function restoreAssistantSource() {
47
- return Object.freeze({ kind: 'plugin', plugin: MESSAGE_TOOLS_PLUGIN, op: 'restore-assistant' });
61
+ return Object.freeze({ kind: MESSAGE_TOOLS_PLUGIN, op: 'restore-assistant' });
48
62
  }
49
- /** Read the op discriminator off a message source (absent on legacy events). */
63
+ /** Read the op discriminator off a message source (absent on plain withdrawal/restore events). */
50
64
  export function messageToolsOp(source) {
51
65
  const op = source?.op;
52
66
  return op === 'edit' || op === 'edit-trigger' || op === 'restore-assistant' ? op : undefined;
53
67
  }
68
+ /**
69
+ * Whether a durable source belongs to message-tools, in every form the log
70
+ * can hold: the current producer-owned kind (`message-tools`), the V3→V4
71
+ * migrated kind (`plugin:message-tools` — released V3 files rewritten once by
72
+ * the official migration), and the released V3 wrapper a pre-V4 host still
73
+ * serves verbatim (`{ kind: 'plugin', plugin: 'message-tools' }`).
74
+ * @param source - the persisted message source to test.
75
+ * @returns true when the source names this producer.
76
+ */
77
+ export function isMessageToolsSource(source) {
78
+ if (typeof source !== 'object' || source === null)
79
+ return false;
80
+ const record = source;
81
+ return record.kind === MESSAGE_TOOLS_PLUGIN
82
+ || record.kind === `plugin:${MESSAGE_TOOLS_PLUGIN}`
83
+ || (record.kind === 'plugin' && record.plugin === MESSAGE_TOOLS_PLUGIN);
84
+ }
54
85
  /**
55
86
  * Strip the model-facing restore frame from a replayed assistant text for
56
87
  * DISPLAY: the frame belongs to the model context (role honesty), never to
@@ -64,16 +95,16 @@ export function stripRestoreAssistantFrame(text) {
64
95
  }
65
96
  /**
66
97
  * Whether the event is a message-tools WITHDRAWAL replacement: a
67
- * `user/message` surface replacement whose source carries this plugin's tag
68
- * and no `op` discriminator (legacy and current withdrawals both).
98
+ * `user/message` surface replacement whose source names this producer
99
+ * (any durable form — see {@link isMessageToolsSource}) with no `op`
100
+ * discriminator.
69
101
  * @param event - the session event to test.
70
102
  * @returns the event narrowed to a withdrawal replacement when matched.
71
103
  */
72
104
  export function isMessageToolsReplacement(event) {
73
105
  return event.type === 'user/message'
74
106
  && isReplacementSurfaceEvent(event)
75
- && event.data.source.kind === 'plugin'
76
- && event.data.source.plugin === MESSAGE_TOOLS_PLUGIN
107
+ && isMessageToolsSource(event.data.source)
77
108
  && messageToolsOp(event.data.source) === undefined;
78
109
  }
79
110
  /**
@@ -85,8 +116,7 @@ export function isMessageToolsReplacement(event) {
85
116
  export function isMessageToolsEdit(event) {
86
117
  return event.type === 'user/message'
87
118
  && isReplacementSurfaceEvent(event)
88
- && event.data.source.kind === 'plugin'
89
- && event.data.source.plugin === MESSAGE_TOOLS_PLUGIN
119
+ && isMessageToolsSource(event.data.source)
90
120
  && messageToolsOp(event.data.source) === 'edit';
91
121
  }
92
122
  /**
@@ -98,21 +128,19 @@ export function isMessageToolsEdit(event) {
98
128
  export function isMessageToolsRestore(event) {
99
129
  return event.type === 'user/message'
100
130
  && event.surfaceOp === 'append'
101
- && event.data.source.kind === 'plugin'
102
- && event.data.source.plugin === MESSAGE_TOOLS_PLUGIN
131
+ && isMessageToolsSource(event.data.source)
103
132
  && messageToolsOp(event.data.source) === undefined;
104
133
  }
105
134
  /**
106
135
  * Whether the event is a message-tools edit trigger: the minimal
107
- * plugin-sourced message that starts the regeneration turn after an edit.
136
+ * producer-sourced message that starts the regeneration turn after an edit.
108
137
  * @param event - the session event to test.
109
138
  * @returns the event narrowed to an edit trigger when matched.
110
139
  */
111
140
  export function isMessageToolsTrigger(event) {
112
141
  return event.type === 'user/message'
113
142
  && event.surfaceOp === 'append'
114
- && event.data.source.kind === 'plugin'
115
- && event.data.source.plugin === MESSAGE_TOOLS_PLUGIN
143
+ && isMessageToolsSource(event.data.source)
116
144
  && messageToolsOp(event.data.source) === 'edit-trigger';
117
145
  }
118
146
  /**
@@ -125,7 +153,6 @@ export function isMessageToolsTrigger(event) {
125
153
  export function isMessageToolsRestoreAssistant(event) {
126
154
  return event.type === 'user/message'
127
155
  && event.surfaceOp === 'append'
128
- && event.data.source.kind === 'plugin'
129
- && event.data.source.plugin === MESSAGE_TOOLS_PLUGIN
156
+ && isMessageToolsSource(event.data.source)
130
157
  && messageToolsOp(event.data.source) === 'restore-assistant';
131
158
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Restore-assistant message projection (seam registry S12): the pure
3
+ * `user/message` interpreter that rewrites a restore replay's derived message
4
+ * to assistant role with the model-facing frame stripped, plus the
5
+ * registration helper that probes the host for the capability and degrades to
6
+ * the framed user-role channel when it cannot carry the projection. The
7
+ * durable log is identical on both channels — the restore append keeps its
8
+ * `restore-assistant` source mark and its frame prefix; only the model-facing
9
+ * derivation differs.
10
+ * @module @khorsheed/dsh-client-message-tools/restore-projection
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis';
13
+ import * as sessionApi from '@deepseek-ai/dsh-session';
14
+ import type { SessionMessageProjection } from '@deepseek-ai/dsh-session';
15
+ /**
16
+ * The `user/message` projection correcting restore replays at derivation
17
+ * time. It registers on the SHARED user-message vocabulary (a custom event
18
+ * type could never persist — S2), so the fold calls `project` synchronously
19
+ * for EVERY user message, inside append validation: it must be a pure
20
+ * pass-through (empty map) for anything without the restore mark, and it must
21
+ * NEVER throw — a throw atomically rejects the candidate append, so a
22
+ * throwing projection would break ordinary user messages instance-wide. Any
23
+ * malformed durable payload therefore also yields an empty map.
24
+ */
25
+ export declare const restoreAssistantProjection: SessionMessageProjection<'user/message'>;
26
+ /**
27
+ * Whether this host's surface fold can carry a `user/message` projection at
28
+ * all. 0.1.6-alpha.1 routes every event of a projected type through the
29
+ * projection plan and never joins it to the surface (probe-verified): a
30
+ * registered user/message projection would silently drop EVERY user message
31
+ * from the model context. This probe folds two synthetic events through the
32
+ * real `foldSurface` and accepts only the composing semantics the design
33
+ * needs — plain messages keep their node, and a restore replay keeps its
34
+ * node AND derives assistant-role with the frame stripped — so the channel
35
+ * stays dormant on 0.1.6-alpha.1 and lights up by itself on a host whose
36
+ * fold composes. Pure, synchronous, side-effect-free.
37
+ * @param fold - the host's surface fold, injectable for tests.
38
+ * @param derive - the host's single-event derivation, injectable for tests.
39
+ * @returns true when a registered user/message projection is safe.
40
+ */
41
+ export declare function restoreProjectionFoldSupported(fold?: typeof sessionApi.foldSurface | undefined, derive?: typeof sessionApi.deriveEventMessage | undefined): boolean;
42
+ /**
43
+ * Probe and register the restore-assistant projection, degrading to the
44
+ * framed user-role channel (which the durable events already carry) whenever
45
+ * the host cannot carry the projection: pre-0.1.6 (`registerMessageProjection`
46
+ * absent), a fold that cannot compose projections with surface membership, or
47
+ * a registration conflict (the projection slot is one-per-type per instance —
48
+ * a future official projection claiming user/message throws, and the plugin
49
+ * must not take the instance down with it).
50
+ * @param ctx - host context carrying the session store.
51
+ * @param foldSupported - fold-semantics probe, injectable for tests.
52
+ * @returns true when the projection channel is active.
53
+ */
54
+ export declare function registerRestoreProjection(ctx: Context, foldSupported?: () => boolean): boolean;
55
+ //# sourceMappingURL=restore-projection.d.ts.map