@anionex/dsh-vision-toolkit 0.1.43 → 0.1.45

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.
@@ -5,8 +5,20 @@
5
5
  * model-visible semantics.
6
6
  * @module dsh-vision-toolkit/tools
7
7
  */
8
- import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools';
8
+ import { defineTool } from '@deepseek-ai/dsh-tools';
9
9
  import { VisionToolkitRuntime } from './runtime.ts';
10
+ /**
11
+ * A value that round-trips through JSON without loss, as the host transports it
12
+ * through `presentationMeta`. DSH 0.1.2 stopped exporting this type from
13
+ * `@deepseek-ai/dsh-tools`, and 0.1.5 keeps it in `@deepseek-ai/dsh-util-values`,
14
+ * a package that does not exist before 0.1.2-alpha.2, so no single specifier
15
+ * covers every release line this package declares. Both homes declare the
16
+ * identical structural union, so redeclaring it here costs nothing and lets old
17
+ * and new hosts compile against the same contract.
18
+ */
19
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
20
+ [key: string]: JsonValue;
21
+ };
10
22
  /** Canonical names shared by registration, bootstrap guidance, and tests. */
11
23
  export declare const VISION_TOOL_NAMES: {
12
24
  readonly glance: "vision_glance";
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,UAAU,EAAE,KAAK,SAAS,EAA6C,MAAM,wBAAwB,CAAA;AAC9G,OAAO,EACL,oBAAoB,EAWrB,MAAM,cAAc,CAAA;AAerB,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB;;;;;;;;;;;CAWpB,CAAA;AAqHV,oFAAoF;AACpF,MAAM,MAAM,0BAA0B,GAAG,oBAAoB,GAAG,CAAC,MAAM,oBAAoB,CAAC,CAAA;AAE5F,kFAAkF;AAClF,MAAM,MAAM,kCAAkC,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,SAAS,CAAA;AAMhF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,0BAA0B,EAClC,mBAAmB,GAAE,kCAAyD,EAC9E,eAAe,CAAC,EAAE,WAAW,GAC5B,UAAU,CAAC,OAAO,UAAU,CAAC,EAAE,CAmZjC"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,UAAU,EAA6C,MAAM,wBAAwB,CAAA;AAC9F,OAAO,EACL,oBAAoB,EAWrB,MAAM,cAAc,CAAA;AAGrB;;;;;;;;GAQG;AACH,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,EAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAA;AAcrG,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB;;;;;;;;;;;CAWpB,CAAA;AAqHV,oFAAoF;AACpF,MAAM,MAAM,0BAA0B,GAAG,oBAAoB,GAAG,CAAC,MAAM,oBAAoB,CAAC,CAAA;AAE5F,kFAAkF;AAClF,MAAM,MAAM,kCAAkC,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,SAAS,CAAA;AAMhF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,0BAA0B,EAClC,mBAAmB,GAAE,kCAAyD,EAC9E,eAAe,CAAC,EAAE,WAAW,GAC5B,UAAU,CAAC,OAAO,UAAU,CAAC,EAAE,CAmZjC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-vision-toolkit",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "DeepSeek Harness-native integration for agent-vision-toolkit: image Q&A, OCR, grounding, UI restoration, pixel diff, Artifacts, and Web UI.",
5
5
  "keywords": [
6
6
  "deepseek",
@@ -55,6 +55,7 @@
55
55
  "runtime",
56
56
  "vendor",
57
57
  "cordis.patch.yml",
58
+ "CHANGELOG.md",
58
59
  "README.md",
59
60
  "README.zh.md",
60
61
  "LICENSE"
@@ -77,13 +78,15 @@
77
78
  },
78
79
  "client": {
79
80
  "inject": [
80
- "@deepseek-ai/dsh-api-remotes",
81
- "@deepseek-ai/dsh-client-runtime",
81
+ "@deepseek-ai/dsh-client-ui-renderer",
82
+ "@deepseek-ai/dsh-client-locale",
83
+ "@deepseek-ai/dsh-api-gateway",
82
84
  "@deepseek-ai/dsh-client-ui-conversation",
85
+ "@deepseek-ai/dsh-api-session-controller",
86
+ "@deepseek-ai/dsh-client-ui-settings-general",
83
87
  "@deepseek-ai/dsh-client-ui-tool",
84
- "@deepseek-ai/dsh-client-ui-settings",
85
- "@deepseek-ai/dsh-client-ui-input-trigger",
86
- "@deepseek-ai/dsh-client-locale"
88
+ "@deepseek-ai/dsh-api-remotes",
89
+ "@deepseek-ai/dsh-client-ui-input-trigger"
87
90
  ],
88
91
  "platform": "web"
89
92
  },
@@ -104,7 +107,10 @@
104
107
  "0.1.2-alpha.5": "compatible",
105
108
  "0.1.2-rc.1": "compatible",
106
109
  "0.1.3-alpha.1": "unknown",
107
- "0.1.3-alpha.2": "compatible"
110
+ "0.1.3-alpha.2": "compatible",
111
+ "0.1.5-alpha.1": "compatible",
112
+ "0.1.5-alpha.2": "compatible",
113
+ "0.1.5-rc.1": "compatible"
108
114
  },
109
115
  "profiles": [
110
116
  "web",
@@ -119,26 +125,25 @@
119
125
  "zod": "4.4.3"
120
126
  },
121
127
  "peerDependencies": {
122
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
123
- "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
124
- "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
125
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
126
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
127
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
128
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.6",
129
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
130
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
131
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
132
- "@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
133
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
134
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
135
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
136
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
137
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
138
- "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
139
- "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.6",
140
- "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
141
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
128
+ "@deepseek-ai/dsh-agent": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
129
+ "@deepseek-ai/dsh-api-remotes": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
130
+ "@deepseek-ai/dsh-attachment": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
131
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
132
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
133
+ "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
134
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
135
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
136
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
137
+ "@deepseek-ai/dsh-client-ui-tool": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
138
+ "@deepseek-ai/dsh-credentials": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
139
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
140
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
141
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
142
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
143
+ "@deepseek-ai/dsh-skill": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
144
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
145
+ "@deepseek-ai/dsh-subprocess": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
146
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
142
147
  "@deepseek-ai/cordis": "^4.0.1",
143
148
  "@deepseek-ai/schemastery": "^3.18.1",
144
149
  "react": "^18.2.0"
@@ -149,10 +154,13 @@
149
154
  }
150
155
  },
151
156
  "devDependencies": {
152
- "@deepseek-ai/dsh-scope": "0.1.0-rc.6",
153
- "@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6",
154
- "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
155
- "@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
157
+ "@deepseek-ai/dsh-client-connection": "0.1.5-rc.1",
158
+ "@deepseek-ai/dsh-client-store": "0.1.5-rc.1",
159
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.5-rc.1",
160
+ "@deepseek-ai/dsh-scope": "0.1.5-rc.1",
161
+ "@deepseek-ai/dsh-subprocess-local": "0.1.5-rc.1",
162
+ "@deepseek-ai/dsh-system-prompt": "0.1.5-rc.1",
163
+ "@deepseek-ai/dsh-tool-skill": "0.1.5-rc.1",
156
164
  "@testing-library/react": "^16.3.2",
157
165
  "@types/node": "^22.20.0",
158
166
  "@types/react": "~18.3.1",
@@ -12,10 +12,10 @@ import { chmod, lstat, mkdir, open, readFile, realpath, writeFile } from 'node:f
12
12
  import type { FileHandle } from 'node:fs/promises'
13
13
  import type { IncomingMessage, ServerResponse } from 'node:http'
14
14
  import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path'
15
- import type { JsonValue } from '@deepseek-ai/dsh-tools'
16
15
  import type { ArtifactDescriptor, ArtifactKind } from './artifacts.ts'
17
16
  import { assertSecureSharedStorageBase, assertSecureWorkspaceStorage, isWithin } from './paths.ts'
18
17
  import { visionToolkitStateRoot } from './runtime-install.ts'
18
+ import type { JsonValue } from './tools.ts'
19
19
 
20
20
  /** Prefix owned by the plugin's artifact capability route. */
21
21
  export const ARTIFACT_ROUTE_PREFIX = '/_dsh/vision-toolkit/artifacts'
@@ -10,12 +10,24 @@ import {
10
10
  type ReactNode,
11
11
  } from 'react'
12
12
  import { Button, Input } from '@deepseek-ai/dsh-client-ui-primitives'
13
- import type { ClientContext, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
14
+ /**
15
+ * `ToolCallBlock` is the one DSH type this bundle consumes by name. DSH 0.1.5
16
+ * publishes it from `@deepseek-ai/dsh-client-ui-conversation/client`; its
17
+ * earlier home, `@deepseek-ai/dsh-client-runtime/client`, stopped publishing
18
+ * after 0.1.1-rc.2 and has no member of the 0.1.5 family at all, so importing
19
+ * the old specifier would leave an unresolvable peer on a real host. The
20
+ * `paths` row in tsconfig.client.json and the peerDependency in package.json
21
+ * name the same package, and both must move together. A type-only import costs
22
+ * nothing at runtime and emits no `require()` in lib/client.js.
23
+ */
24
+ import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client'
14
25
  import type {} from '@deepseek-ai/dsh-api-remotes/client'
26
+ import type {} from '@deepseek-ai/dsh-client-connection/client'
15
27
  import type {} from '@deepseek-ai/dsh-credentials/types'
16
28
  import type {} from '@deepseek-ai/dsh-settings/types'
17
- import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
18
29
  import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
30
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
19
31
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
20
32
  import type {} from '@deepseek-ai/dsh-client-locale/client'
21
33
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -1572,7 +1584,12 @@ export function apply(ctx: ClientContext): void {
1572
1584
  const disposers = typeof legacyRemote.$on === 'function'
1573
1585
  ? [
1574
1586
  legacyRemote.$on('settings/document-updated', refreshSettings),
1575
- legacyRemote.$on('credentials/updated', refreshCredential),
1587
+ // DSH forwards `credentials/reference-updated` (payload: the credential
1588
+ // ref whose stored value committed) and never `credentials/updated`;
1589
+ // `$on` accepts unknown names silently, so the wrong key was a dead
1590
+ // subscription. Both the 0.1.2-alpha.1 and 0.1.5 allowlists carry this
1591
+ // exact name, so one key covers every supported host.
1592
+ legacyRemote.$on('credentials/reference-updated', refreshCredential),
1576
1593
  ]
1577
1594
  : [
1578
1595
  currentEvents.on('settings/changed', (namespace) => {
@@ -1,9 +1,10 @@
1
1
  /** Clipboard-only multi-image input for DSH Web. */
2
2
 
3
3
  import { useSyncExternalStore, type ReactNode } from 'react'
4
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
5
- import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
4
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
5
+ import type { DraftAttachmentId } from '@deepseek-ai/dsh-client-ui-conversation/client'
6
6
  import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
7
8
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
9
  import { readDisplayConfig } from './display-config.ts'
9
10
 
@@ -17,6 +18,47 @@ const MAX_BATCH_BYTES = 80 * 1024 * 1024
17
18
  /** A confirmed paste verdict older than this is unknown again, even while a refresh is in flight. */
18
19
  const VERDICT_MAX_AGE_MS = 15000
19
20
 
21
+ /**
22
+ * Count the draft attachment ids in an InputState snapshot across DSH
23
+ * generations. DSH 0.1.5 renamed the field to `attachmentIds` and widened its
24
+ * meaning from image-only ids to every draft attachment id; the older
25
+ * `imageIds` name survives only as a compatibility fallback for hosts that
26
+ * still publish it, and never crashes when it is absent.
27
+ * @param snapshot - the `input.state.getSnapshot()` value (either generation).
28
+ * @returns the draft attachment count, 0 when neither field is published.
29
+ */
30
+ function draftAttachmentCount(snapshot: unknown): number {
31
+ if (snapshot === null || typeof snapshot !== 'object') return 0
32
+ const row = snapshot as {
33
+ attachmentIds?: readonly DraftAttachmentId[]
34
+ imageIds?: readonly string[]
35
+ }
36
+ if (Array.isArray(row.attachmentIds)) return row.attachmentIds.length
37
+ return Array.isArray(row.imageIds) ? row.imageIds.length : 0
38
+ }
39
+
40
+ /**
41
+ * The Client Session registry face this controller reads. DSH 0.1.5 publishes
42
+ * it as `Context.sessions` from `@deepseek-ai/dsh-api-session-controller/client`,
43
+ * whose peer set spans the whole host package graph and therefore cannot be a
44
+ * peer of a Web-only plugin; only these two members are used, so the face is
45
+ * declared structurally instead of importing that package.
46
+ */
47
+ interface ClientSessionRegistry {
48
+ /** Resolve the Session-scoped Context; undefined once the Session has closed. */
49
+ scope(sessionId: never): ClientContext | undefined
50
+ /** Live Session list projection; `current` is the focused Session id. */
51
+ readonly list: { getSnapshot(): { readonly current?: string | undefined } }
52
+ }
53
+
54
+ /**
55
+ * @param ctx - the browser plugin Context.
56
+ * @returns the host's Session registry through the structural face above.
57
+ */
58
+ function sessionRegistry(ctx: ClientContext): ClientSessionRegistry {
59
+ return (ctx as unknown as { readonly sessions: ClientSessionRegistry }).sessions
60
+ }
61
+
20
62
  interface PasteRecord {
21
63
  ref: string
22
64
  file: File
@@ -222,7 +264,7 @@ export class PasteImageController {
222
264
  }
223
265
 
224
266
  private inputFor(sessionId: string) {
225
- const actx = this.ctx.sessions.scope(sessionId as never)
267
+ const actx = sessionRegistry(this.ctx).scope(sessionId as never)
226
268
  if (actx === undefined) throw new Error('Open a live session before pasting images')
227
269
  return this.ctx.conversation.input.for(actx)
228
270
  }
@@ -482,7 +524,9 @@ export class PasteImageController {
482
524
  * Auto-switch flow: switch the Session to the image-input variant, announce
483
525
  * it, then replay the paste into the composer's native intake. A failed
484
526
  * switch, or an environment that cannot replay clipboard bytes, degrades to
485
- * the path takeover with the same files.
527
+ * the path takeover with the same files. The post-replay probe compares the
528
+ * draft attachment count before and after, so it observes any attachment the
529
+ * native intake admitted rather than images alone.
486
530
  * @param sessionId - the live Session id.
487
531
  * @param target - the composer textarea the paste landed on.
488
532
  * @param files - the captured image files.
@@ -511,10 +555,10 @@ export class PasteImageController {
511
555
  // Replaying lets the composer's own intake run (thumbnail, limits,
512
556
  // keyboard); if the environment cannot replay clipboard bytes, the
513
557
  // images still land as workspace paths.
514
- const before = input.state.getSnapshot().imageIds.length
558
+ const attachmentsBefore = draftAttachmentCount(input.state.getSnapshot())
515
559
  const replayed = this.replayPaste(target, files, text)
516
- const after = input.state.getSnapshot().imageIds.length
517
- if (!replayed || after <= before) {
560
+ const attachmentsAfter = draftAttachmentCount(input.state.getSnapshot())
561
+ if (!replayed || attachmentsAfter <= attachmentsBefore) {
518
562
  this.takeoverPaste(sessionId, target, files, text)
519
563
  }
520
564
  }
@@ -558,7 +602,7 @@ export class PasteImageController {
558
602
  const target = event.target
559
603
  if (!(target instanceof HTMLTextAreaElement) || target.closest('[data-composer-card]') === null) return false
560
604
 
561
- const sessionId = this.ctx.sessions.list.getSnapshot().current
605
+ const sessionId = sessionRegistry(this.ctx).list.getSnapshot().current
562
606
  if (sessionId === undefined) return false
563
607
  const modelLabel = currentModelLabel()
564
608
  this.refreshVerdict(sessionId, modelLabel)
@@ -735,7 +779,7 @@ export function installPasteImages(ctx: ClientContext): void {
735
779
  const listener = (event: ClipboardEvent): void => { controller.handlePaste(event) }
736
780
  // A focus-time prefetch has the verdict ready before the first paste can land.
737
781
  const onFocusIn = (): void => {
738
- const sessionId = ctx.sessions.list.getSnapshot().current
782
+ const sessionId = sessionRegistry(ctx).list.getSnapshot().current
739
783
  if (sessionId !== undefined) controller.refreshVerdict(String(sessionId), currentModelLabel())
740
784
  }
741
785
  document.addEventListener('paste', listener, true)
package/src/exposure.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import type { Agent } from '@deepseek-ai/dsh-agent'
10
10
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
11
11
  import type { Session } from '@deepseek-ai/dsh-session'
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
12
13
  import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
13
14
  import type { Context } from '@deepseek-ai/cordis'
14
15
  import { VISION_SKILLS_CONTENT, VISION_SKILLS_NAME } from './skill.ts'
@@ -78,12 +79,49 @@ function isBundledSkillResult(value: unknown): boolean {
78
79
  && isBundledSkillContent(value.content)
79
80
  }
80
81
 
82
+ /** Event names for one settled programmatic (PTC) Tool dispatch. */
83
+ const PTC_DISPATCH_EVENT_TYPES: readonly string[] = ['tool/ptc-dispatch', 'tool/code-dispatch']
84
+
85
+ /**
86
+ * Whether one PTC dispatch event records a successful load of the bundled
87
+ * Skill. The event is `tool/code-dispatch` through 0.1.2 and `tool/ptc-dispatch`
88
+ * from 0.1.5, so the name is compared outside the typed union and the payload is
89
+ * read structurally instead of narrowing on the installed host's literal.
90
+ * @param event - one durable Session event.
91
+ * @returns true when this dispatch ran the bundled Skill and returned its content.
92
+ */
93
+ function isBundledSkillPtcDispatch(event: SessionEvent): boolean {
94
+ const type: string = event.type
95
+ if (!PTC_DISPATCH_EVENT_TYPES.includes(type)) return false
96
+ const data = (event as { data?: unknown }).data
97
+ return isRecord(data)
98
+ && data.name === 'skill'
99
+ && data.isError === false
100
+ && isVisionSkillArguments(data.arguments)
101
+ && containsBundledSkillContent(Array.isArray(data.content) ? data.content : [])
102
+ }
103
+
104
+ /**
105
+ * Read a Session's durable event log across both published accessors: 0.1.1 and
106
+ * earlier expose the `events` getter, while 0.1.2 replaced it with
107
+ * `snapshotEvents()` and dropped the property from the type. Probing
108
+ * structurally keeps every release line this package declares working at
109
+ * runtime and still compiles against the installed 0.1.5 typings.
110
+ * @param session - the Session whose durable history is inspected.
111
+ * @returns the event log in log order, empty when neither accessor is present.
112
+ */
113
+ function sessionEvents(session: Session): readonly SessionEvent[] {
114
+ const adapter = session as unknown as {
115
+ snapshotEvents?: () => readonly SessionEvent[]
116
+ events?: readonly SessionEvent[]
117
+ }
118
+ return typeof adapter.snapshotEvents === 'function' ? adapter.snapshotEvents() : adapter.events ?? []
119
+ }
120
+
81
121
  /** Whether durable history proves that this Session loaded the bundled Skill. */
82
122
  function hasLoadedVisionSkill(session: Session): boolean {
83
123
  const nativeCalls = new Set<string>()
84
- const events = typeof (session as any).snapshotEvents === 'function'
85
- ? (session as any).snapshotEvents()
86
- : session.events ?? []
124
+ const events = sessionEvents(session)
87
125
  for (const event of events) {
88
126
  if (event.type === 'user/message') {
89
127
  const source = event.data.source
@@ -106,11 +144,7 @@ function hasLoadedVisionSkill(session: Session): boolean {
106
144
  && containsBundledSkillContent(block.content)) return true
107
145
  continue
108
146
  }
109
- if (event.type === 'tool/code-dispatch'
110
- && event.data.name === 'skill'
111
- && event.data.isError === false
112
- && isVisionSkillArguments(event.data.arguments)
113
- && containsBundledSkillContent(event.data.content)) return true
147
+ if (isBundledSkillPtcDispatch(event)) return true
114
148
  }
115
149
  return false
116
150
  }
@@ -14,7 +14,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
14
14
  import { tmpdir } from 'node:os'
15
15
  import { isAbsolute, join, resolve } from 'node:path'
16
16
  import type { Context } from '@deepseek-ai/cordis'
17
- import LlmService, { LlmAdapter, contentHasImage } from '@deepseek-ai/dsh-llm'
17
+ import LlmService, { LlmAdapter, contentHasImage, freezeMessage } from '@deepseek-ai/dsh-llm'
18
18
  import type {
19
19
  ContentBlock,
20
20
  GenerateOptions,
@@ -23,6 +23,7 @@ import type {
23
23
  LlmProviderInfo,
24
24
  LlmResolvedModelInfo,
25
25
  Message,
26
+ ModelMessageSource,
26
27
  ResolvedRetryPolicy,
27
28
  StreamChunk,
28
29
  } from '@deepseek-ai/dsh-llm'
@@ -508,6 +509,80 @@ export async function convertImagesToEvidence(
508
509
  return converted
509
510
  }
510
511
 
512
+ /**
513
+ * Present one delegated request's assistant history under the upstream route,
514
+ * restoring the adapter-private replay state the host withholds from a facade.
515
+ *
516
+ * The host hands `replayState` to a target adapter only while that exact
517
+ * adapter instance owns both the historical provider and the target provider.
518
+ * A variant route is a different instance, so history produced under the
519
+ * upstream route arrives stripped, and history produced under the variant
520
+ * route is stripped again inside the delegation (the upstream adapter does not
521
+ * own the variant route either). Losing that state costs provider-native
522
+ * fidelity — thinking signatures, native effort binding, response ids — which
523
+ * makes an upstream that needs reasoning continuity answer without thinking
524
+ * while still being asked for it, and the host reports only a state it could
525
+ * not use, never one it withheld.
526
+ *
527
+ * The durable transcript is the authority for what was withheld: history
528
+ * messages of this wrapper's own routes are matched by id, and only the request
529
+ * copy is re-provenanced. The Session itself is never touched, so the durable
530
+ * log keeps the route the user actually selected.
531
+ * @param upstream - upstream route the delegated request is dispatched under.
532
+ * @param messages - messages about to be delegated to that route.
533
+ * @param transcript - reads the durable Session transcript on demand, if any.
534
+ * @returns the messages to delegate: the input array when nothing needed repair.
535
+ */
536
+ function presentUnderUpstreamRoute(
537
+ upstream: string,
538
+ messages: Message[],
539
+ transcript: () => readonly Message[] | undefined,
540
+ ): Message[] {
541
+ const variant = variantProviderId(upstream)
542
+ let durable: Map<string, Message> | undefined
543
+ let presented: Message[] | undefined
544
+ messages.forEach((message, index) => {
545
+ const source = message.source
546
+ if (message.role !== 'assistant' || source.kind !== 'model') return
547
+ if (source.provider !== upstream && source.provider !== variant) return
548
+ let replayState = source.replayState
549
+ if (replayState === undefined) {
550
+ durable ??= new Map(
551
+ (transcript() ?? [])
552
+ .filter(candidate => candidate.role === 'assistant')
553
+ .map(candidate => [String(candidate.id), candidate] as const),
554
+ )
555
+ const withheld = durable.get(String(message.id))
556
+ // Provenance and shape must still describe this very message: a Session
557
+ // that was rewritten under us (a fold, a rewind) must not have one
558
+ // message's state reattached to another's content.
559
+ if (withheld !== undefined && withheld.source.kind === 'model'
560
+ && withheld.source.provider === source.provider
561
+ && withheld.source.replayState !== undefined
562
+ && sameBlockShape(withheld.content, message.content)) {
563
+ replayState = withheld.source.replayState
564
+ }
565
+ }
566
+ // Nothing withheld and no re-provenancing to do: leave the message alone.
567
+ if (source.provider === upstream && replayState === undefined) return
568
+ const replayed: ModelMessageSource = {
569
+ kind: 'model',
570
+ provider: upstream,
571
+ model: source.model,
572
+ ...(replayState === undefined ? {} : { replayState }),
573
+ }
574
+ presented ??= [...messages]
575
+ presented[index] = freezeMessage({ ...message, source: replayed })
576
+ })
577
+ return presented ?? messages
578
+ }
579
+
580
+ /** Whether two block lists carry the same block types in the same order. */
581
+ function sameBlockShape(left: readonly ContentBlock[], right: readonly ContentBlock[]): boolean {
582
+ return left.length === right.length
583
+ && left.every((block, index) => block.type === right[index]?.type)
584
+ }
585
+
511
586
  /**
512
587
  * The adapter behind one variant route: model metadata declares image input,
513
588
  * and every stream rewrites image blocks before delegating to the upstream
@@ -539,6 +614,18 @@ export class ImageInputVariantAdapter extends LlmAdapter {
539
614
  return this.llm.providerRetryPolicy(this.upstream)
540
615
  }
541
616
 
617
+ /**
618
+ * Read the durable transcript of one Session, when the host still holds it.
619
+ * Only `presentUnderUpstreamRoute` consumes it, and only for messages whose
620
+ * replay state the host withheld from this facade route.
621
+ * @param sessionId - Session whose history is being delegated, if any.
622
+ * @returns the durable messages, or undefined without a live Session.
623
+ */
624
+ private durableTranscript(sessionId: GenerateOptions['sessionId']): readonly Message[] | undefined {
625
+ if (sessionId === undefined) return undefined
626
+ return this.ctx.sessions.get(sessionId as never)?.deriveMessages()
627
+ }
628
+
542
629
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
543
630
  const models = await this.llm.listModels(this.upstream)
544
631
  return models.filter(shouldWrapModel).map((model) => ({
@@ -605,9 +692,17 @@ export class ImageInputVariantAdapter extends LlmAdapter {
605
692
  .digest('hex'),
606
693
  storageDir,
607
694
  )
608
- // Delegate through the host service under the upstream route: the variant
609
- // is a wire-only facade, and the upstream route owns retry and replay.
610
- yield* this.llm.stream({ ...options, provider: this.upstream, messages })
695
+ // Delegate through the host service under the upstream route, so the
696
+ // upstream's own middleware, retry policy, and replay handling still apply.
697
+ // Replay state is host-owned and route-scoped, so the history this facade
698
+ // route cannot receive back is restored before that delegation: the durable
699
+ // log keeps the user's route, only the delegated copy is re-provenanced.
700
+ const delegated = presentUnderUpstreamRoute(
701
+ this.upstream,
702
+ messages,
703
+ () => this.durableTranscript(options.sessionId),
704
+ )
705
+ yield* this.llm.stream({ ...options, provider: this.upstream, messages: delegated })
611
706
  }
612
707
  }
613
708
 
package/src/tools.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
10
- import { defineTool, type JsonValue, type ToolRunContext, type ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
10
+ import { defineTool, type ToolRunContext, type ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
11
11
  import {
12
12
  VisionToolkitRuntime,
13
13
  type CropRequest,
@@ -23,6 +23,17 @@ import {
23
23
  } from './runtime.ts'
24
24
  import { platformTempDirectory } from './paths.ts'
25
25
 
26
+ /**
27
+ * A value that round-trips through JSON without loss, as the host transports it
28
+ * through `presentationMeta`. DSH 0.1.2 stopped exporting this type from
29
+ * `@deepseek-ai/dsh-tools`, and 0.1.5 keeps it in `@deepseek-ai/dsh-util-values`,
30
+ * a package that does not exist before 0.1.2-alpha.2, so no single specifier
31
+ * covers every release line this package declares. Both homes declare the
32
+ * identical structural union, so redeclaring it here costs nothing and lets old
33
+ * and new hosts compile against the same contract.
34
+ */
35
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
36
+
26
37
  const renderJson = (_args: unknown, value: unknown): ContentBlock[] => [{
27
38
  type: 'text',
28
39
  text: JSON.stringify(value, null, 2),