codsh-bundle 0.7.0 → 0.8.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/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import z from "@deepseek-ai/schemastery";
6
6
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
7
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
8
8
  import { admitEncodedImages, isImageAdmissionError } from "@deepseek-ai/dsh-attachment";
9
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
9
+ import { BlockAssembler, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
10
10
  import { SessionId } from "@deepseek-ai/dsh-session";
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import stringWidth from "string-width";
@@ -4970,6 +4970,8 @@ var Spinner = class {
4970
4970
  //#region src/vision.ts
4971
4971
  /** How long one description may take before the paste falls back to file-only. */
4972
4972
  const VISION_TIMEOUT_MS = 3e4;
4973
+ /** The sibling route that lends sight to DeepSeek's text-only models. */
4974
+ const DEEPSEEK_VISION_MODEL = "deepseek-v4-flash-vision-exp";
4973
4975
  /**
4974
4976
  * The one instruction the sidecar gets.
4975
4977
  *
@@ -4978,6 +4980,8 @@ const VISION_TIMEOUT_MS = 3e4;
4978
4980
  * code is exactly the part the coding agent needed.
4979
4981
  */
4980
4982
  const VISION_PROMPT = "You are the eyes for a text-only coding agent. Describe this image precisely and completely. Transcribe ALL visible text, code, commands, error messages, numbers and labels verbatim. When it shows a UI, terminal, diagram or chart, describe its structure and layout so the agent can reason about it. Do not speculate beyond what is visible.";
4983
+ /** How the conversation model should consume a successful visual handoff. */
4984
+ const DESCRIPTION_HANDLING = "A vision model has already inspected this image. Treat the description as the image content available to you and answer the user directly. Do not say that you cannot see, read, access, or directly inspect the image, and do not mention the description, metadata, or vision handoff unless the user asks how image handling works.";
4981
4985
  /**
4982
4986
  * The sidecar from the environment, or undefined when none is configured.
4983
4987
  * @param env - the process environment.
@@ -5032,6 +5036,54 @@ async function describeImage(image, config, signal) {
5032
5036
  return text;
5033
5037
  }
5034
5038
  /**
5039
+ * Ask DeepSeek's native vision sibling to describe one durable image.
5040
+ *
5041
+ * This is deliberately a one-shot auxiliary call: it receives no conversation
5042
+ * history, system prompt, or tools, and its answer is returned as plain text
5043
+ * for the selected text model's ordinary user turn.
5044
+ * @param image - the image reference already admitted to the durable store.
5045
+ * @param llm - the provider-neutral runtime serving the DeepSeek route.
5046
+ * @param options - optional caller cancellation and request attribution.
5047
+ * @returns the visible text emitted by the vision model.
5048
+ * @throws when the route cannot see, the provider fails, the call is aborted,
5049
+ * or the response contains no visible text.
5050
+ */
5051
+ async function describeImageWithLlm(image, llm, options = {}) {
5052
+ const timeout = AbortSignal.timeout(VISION_TIMEOUT_MS);
5053
+ const signal = options.signal === void 0 ? timeout : AbortSignal.any([options.signal, timeout]);
5054
+ const prepared = await llm.prepareCall({
5055
+ provider: "deepseek-official",
5056
+ model: DEEPSEEK_VISION_MODEL,
5057
+ reasoningEffort: ReasoningEffortId("off")
5058
+ }, signal);
5059
+ if (prepared.inputModalities?.includes("image") !== true) throw new Error(`${DEEPSEEK_VISION_MODEL} does not accept image input`);
5060
+ const assembler = new BlockAssembler();
5061
+ const message = createUserMessage({
5062
+ content: [{
5063
+ type: "image",
5064
+ attachment: image
5065
+ }, {
5066
+ type: "text",
5067
+ text: VISION_PROMPT
5068
+ }],
5069
+ source: {
5070
+ kind: "plugin",
5071
+ plugin: "coding-cli"
5072
+ }
5073
+ });
5074
+ for await (const chunk of prepared.stream({
5075
+ ...prepared.config,
5076
+ messages: [message],
5077
+ signal,
5078
+ ...options.sessionId === void 0 ? {} : { sessionId: options.sessionId }
5079
+ })) assembler.push(chunk);
5080
+ const finish = assembler.finish;
5081
+ if (finish.kind === "error" || finish.kind === "aborted") throw new Error(`vision model ${finish.kind}: ${finish.failure.message}`);
5082
+ const text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
5083
+ if (text === "") throw new Error("vision model answered without text");
5084
+ return text;
5085
+ }
5086
+ /**
5035
5087
  * The upstream store's default admission limits, for use when no store is
5036
5088
  * mounted: the sidecar payload is bounded by the same line either way.
5037
5089
  */
@@ -5087,7 +5139,7 @@ async function savePastedImage(image) {
5087
5139
  */
5088
5140
  function pastedImageBlock(id, image, at) {
5089
5141
  const size = at.width !== void 0 && at.height !== void 0 ? ` dimensions="${at.width}x${at.height}"` : "";
5090
- const body = at.description === void 0 ? "" : `\n<description>\n${at.description}\n</description>`;
5142
+ const body = at.description === void 0 ? "" : `\n<description>\n${at.description}\n</description>\n<handling>\n${DESCRIPTION_HANDLING}\n</handling>`;
5091
5143
  return `<pasted-image id="${id}" media="${image.mediaType}"${size} path="${at.path}">${body}\n</pasted-image>`;
5092
5144
  }
5093
5145
  /**
@@ -6163,17 +6215,19 @@ async function run(ctx, config, io) {
6163
6215
  * modalities or a failed resolution get the text fallback, which degrades,
6164
6216
  * where the block path would crash the turn.
6165
6217
  */
6166
- const routeAcceptsImages = async () => {
6218
+ const routeImageCapability = async () => {
6167
6219
  const current = selection.current;
6168
- if (current === void 0) return false;
6220
+ if (current === void 0) return void 0;
6169
6221
  const llm = ctx.get("llm");
6170
- if (llm === void 0) return false;
6222
+ if (llm === void 0) return void 0;
6171
6223
  try {
6172
- return (await llm.resolveModelInfo(current.provider, current.model)).inputModalities?.includes("image") === true;
6224
+ const resolved = await llm.resolveModelInfo(current.provider, current.model);
6225
+ return resolved.inputModalities === void 0 ? void 0 : resolved.inputModalities.includes("image");
6173
6226
  } catch {
6174
- return false;
6227
+ return;
6175
6228
  }
6176
6229
  };
6230
+ const routeAcceptsImages = async () => await routeImageCapability() === true;
6177
6231
  /**
6178
6232
  * Resolve a /model argument to a selection.
6179
6233
  * @param typed - a bare model id, or an explicit `provider/model`.
@@ -6845,20 +6899,20 @@ async function run(ctx, config, io) {
6845
6899
  /**
6846
6900
  * Turn pasted images into what this turn's message can carry.
6847
6901
  *
6848
- * Three exits, decided by the route. An image-capable model gets the images
6849
- * as first-class blocks through the durable store the runtime's own path.
6850
- * A text-only model gets each image saved as a file plus, when the vision
6851
- * sidecar is configured, a description standing in for sight; both ride the
6852
- * same message so they persist for `--resume`. A failure never loses the
6853
- * turn: it flashes, and the text still goes.
6902
+ * An image-capable model gets first-class blocks through the durable store.
6903
+ * A text-only model gets each image saved as a file plus a description from
6904
+ * either the configured sidecar or DeepSeek's built-in Vision Exp bridge;
6905
+ * both ride the same message so they persist for `--resume`. A recognition
6906
+ * failure never loses the turn: it flashes, and the saved path still goes.
6854
6907
  * @param images - the submission's pasted images, in token order.
6855
6908
  * @returns blocks around the text, or undefined when there is nothing extra.
6856
6909
  */
6857
- const prepareImages = async (images) => {
6910
+ const prepareImages = async (images, signal) => {
6858
6911
  if (images.length === 0) return void 0;
6859
6912
  const store = ctx.get("attachments");
6860
6913
  const limits = store?.imageLimits ?? DEFAULT_IMAGE_LIMITS;
6861
- if (await routeAcceptsImages() && store !== void 0) try {
6914
+ const currentAcceptsImages = await routeImageCapability();
6915
+ if (currentAcceptsImages === true && store !== void 0) try {
6862
6916
  return {
6863
6917
  leading: (await admitEncodedImages(store, await Promise.all(images.map((pending) => fitWithinLimits(pending.image, limits))))).map((attachment) => ({
6864
6918
  type: "image",
@@ -6872,6 +6926,9 @@ async function run(ctx, config, io) {
6872
6926
  if (!isImageAdmissionError(error)) return void 0;
6873
6927
  }
6874
6928
  const vision = visionConfigFromEnv(process.env);
6929
+ const current = selection.current;
6930
+ const llm = ctx.get("llm");
6931
+ const automaticVision = vision === void 0 && currentAcceptsImages === false && current?.provider === "deepseek-official" && llm !== void 0 && store !== void 0;
6875
6932
  const trailing = [];
6876
6933
  for (const pending of images) {
6877
6934
  const at = { path: await savePastedImage(pending.image) };
@@ -6880,8 +6937,25 @@ async function run(ctx, config, io) {
6880
6937
  if (vision !== void 0) {
6881
6938
  prompt.setHint(theme.dim(` ✻ describing image #${pending.id} with ${vision.model}…`));
6882
6939
  try {
6883
- at.description = await describeImage(await fitWithinLimits(pending.image, limits), vision);
6940
+ at.description = await describeImage(await fitWithinLimits(pending.image, limits), vision, signal);
6884
6941
  } catch (error) {
6942
+ if (signal?.aborted === true) throw error;
6943
+ const reason = error instanceof Error ? error.message : String(error);
6944
+ prompt.setFlash(theme.error(truncate(` image #${pending.id}: description failed (${reason}) — attached as file only`, io.console.columns)));
6945
+ } finally {
6946
+ prompt.setHint(void 0);
6947
+ }
6948
+ } else if (automaticVision) {
6949
+ prompt.setHint(theme.dim(` ✻ describing image #${pending.id} with ${DEEPSEEK_VISION_MODEL}…`));
6950
+ try {
6951
+ const [attachment] = await admitEncodedImages(store, [await fitWithinLimits(pending.image, limits)]);
6952
+ if (attachment === void 0) throw new Error("vision image was not admitted");
6953
+ at.description = await describeImageWithLlm(attachment, llm, {
6954
+ ...signal === void 0 ? {} : { signal },
6955
+ sessionId: live.agent.session.id
6956
+ });
6957
+ } catch (error) {
6958
+ if (signal?.aborted === true) throw error;
6885
6959
  const reason = error instanceof Error ? error.message : String(error);
6886
6960
  prompt.setFlash(theme.error(truncate(` image #${pending.id}: description failed (${reason}) — attached as file only`, io.console.columns)));
6887
6961
  } finally {
@@ -6899,7 +6973,18 @@ async function run(ctx, config, io) {
6899
6973
  };
6900
6974
  };
6901
6975
  const answer = async (text, source, images = []) => {
6902
- const extra = await prepareImages(images);
6976
+ const preparing = images.length === 0 ? void 0 : new AbortController();
6977
+ if (preparing !== void 0) running = preparing;
6978
+ let extra;
6979
+ try {
6980
+ extra = await prepareImages(images, preparing?.signal);
6981
+ if (preparing?.signal.aborted === true) return;
6982
+ } catch (error) {
6983
+ if (preparing?.signal.aborted === true) return;
6984
+ throw error;
6985
+ } finally {
6986
+ if (running === preparing) running = void 0;
6987
+ }
6903
6988
  const before = totalTokens(facts(branch).usage) ?? 0;
6904
6989
  turnBaseTokens = before;
6905
6990
  const started = performance.now();
@@ -4,14 +4,16 @@
4
4
  * DeepSeek Vision routes receive first-class image blocks before this module
5
5
  * is involved. For text-only routes such as Flash and Pro, codsh gives an
6
6
  * image two honest lives: it is always saved to a stable file the agent's
7
- * tools can touch — inspect, commit, embed. And when a vision sidecar is
8
- * configured (`CODSH_VISION_*`: any OpenAI-compatible multimodal endpoint),
9
- * the image is also described into text the model can actually read: everything in it
10
- * transcribed, structure narrated. Both ride the same message the person
7
+ * tools can touch — inspect, commit, embed. An explicit `CODSH_VISION_*`
8
+ * sidecar, or DeepSeek's built-in Vision Exp bridge when no sidecar is set,
9
+ * can also describe it into text the model can actually read: everything in
10
+ * it transcribed, structure narrated. Both ride the same message the person
11
11
  * sent, so they persist in durable history and survive `--resume`.
12
12
  * @module codsh-bundle/src/vision
13
13
  */
14
- import type { EncodedImageAttachment, ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment/types';
14
+ import type { EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types';
15
+ import type { GenerateOptions, LlmCallConfig, ModelModality, StreamChunk } from '@deepseek-ai/dsh-llm';
16
+ import type { SessionId } from '@deepseek-ai/dsh-session';
15
17
  /** A vision sidecar: an OpenAI-compatible endpoint that can see. */
16
18
  export interface VisionConfig {
17
19
  /** The API base, e.g. `https://open.bigmodel.cn/api/paas/v4`. */
@@ -21,6 +23,16 @@ export interface VisionConfig {
21
23
  /** The multimodal model to ask. */
22
24
  model: string;
23
25
  }
26
+ /** The sibling route that lends sight to DeepSeek's text-only models. */
27
+ export declare const DEEPSEEK_VISION_MODEL = "deepseek-v4-flash-vision-exp";
28
+ /** The provider-owned preparation seam needed for one auxiliary vision call. */
29
+ export interface VisionLlm {
30
+ prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<{
31
+ readonly config: LlmCallConfig;
32
+ readonly inputModalities?: readonly ModelModality[];
33
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
34
+ }>;
35
+ }
24
36
  /**
25
37
  * The sidecar from the environment, or undefined when none is configured.
26
38
  * @param env - the process environment.
@@ -36,6 +48,23 @@ export declare function visionConfigFromEnv(env: Record<string, string | undefin
36
48
  * @throws on timeout, a non-2xx answer, or an answer with no text.
37
49
  */
38
50
  export declare function describeImage(image: EncodedImageAttachment, config: VisionConfig, signal?: AbortSignal): Promise<string>;
51
+ /**
52
+ * Ask DeepSeek's native vision sibling to describe one durable image.
53
+ *
54
+ * This is deliberately a one-shot auxiliary call: it receives no conversation
55
+ * history, system prompt, or tools, and its answer is returned as plain text
56
+ * for the selected text model's ordinary user turn.
57
+ * @param image - the image reference already admitted to the durable store.
58
+ * @param llm - the provider-neutral runtime serving the DeepSeek route.
59
+ * @param options - optional caller cancellation and request attribution.
60
+ * @returns the visible text emitted by the vision model.
61
+ * @throws when the route cannot see, the provider fails, the call is aborted,
62
+ * or the response contains no visible text.
63
+ */
64
+ export declare function describeImageWithLlm(image: ImageAttachmentRef, llm: VisionLlm, options?: {
65
+ signal?: AbortSignal;
66
+ sessionId?: SessionId;
67
+ }): Promise<string>;
39
68
  /**
40
69
  * The upstream store's default admission limits, for use when no store is
41
70
  * mounted: the sidecar payload is bounded by the same line either way.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codsh-bundle",
3
3
  "description": "The codsh runtime: the interactive TTY surface and code-cli agent preset, installed into a dsh profile. Users install codsh-cli (the launcher) — this package is what it registers.",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "lib/index.js",