@prur/dsh-chat-service 0.1.15 → 0.1.17

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/engine.d.ts CHANGED
@@ -4,20 +4,48 @@
4
4
  * in this repo directly against these functions.
5
5
  * @module @prur/dsh-chat-service/src/engine
6
6
  */
7
- import type { ChatMessage } from './types.ts';
7
+ import type { ChatImageAttachmentRef, ChatMessage } from './types.ts';
8
8
  /** Truncated title derived from the first user message. */
9
9
  export declare const TITLE_MAX_CHARS = 30;
10
10
  /** Rough token estimate (4 chars per token) for context-window trimming. */
11
11
  export declare const CHARS_PER_TOKEN = 4;
12
+ /**
13
+ * Rough token estimate for one image block: one token per 85×85 tile
14
+ * (the conventional vision-model pricing granularity), rounding up.
15
+ */
16
+ export declare const IMAGE_TILE_EDGE = 85;
12
17
  /** Context-window utilization kept under this fraction before trimming. */
13
18
  export declare const CONTEXT_WINDOW_SAFETY = 0.9;
19
+ /** One model-facing content part produced by context assembly. */
20
+ export type AssembledContentPart = {
21
+ readonly type: 'text';
22
+ readonly text: string;
23
+ } | {
24
+ readonly type: 'image';
25
+ readonly attachment: ChatImageAttachmentRef;
26
+ };
27
+ /**
28
+ * The producer provenance DSH's `ctx.llm.stream` requires on every message
29
+ * (`Message.source`, `message.ts`). Without it the llm layer's `forAdapter`
30
+ * reads `source.kind` on an assistant message and throws
31
+ * "Cannot read properties of undefined (reading 'kind')" — the chat multi-turn
32
+ * failure. A user message is `{kind:'user'}`; an assistant message is
33
+ * `{kind:'model', provider, model}` (no `replayState`, so the llm layer returns
34
+ * the message unchanged rather than rewriting its provenance).
35
+ */
36
+ export type AssembledMessageSource = {
37
+ readonly kind: 'user';
38
+ } | {
39
+ readonly kind: 'model';
40
+ readonly provider: string;
41
+ readonly model: string;
42
+ };
14
43
  /** One model-facing message produced by context assembly. */
15
44
  export interface AssembledMessage {
16
45
  readonly role: 'user' | 'assistant';
17
- readonly content: readonly {
18
- readonly type: 'text';
19
- readonly text: string;
20
- }[];
46
+ readonly content: readonly AssembledContentPart[];
47
+ /** Required by DSH's llm layer; see {@link AssembledMessageSource}. */
48
+ readonly source: AssembledMessageSource;
21
49
  }
22
50
  /** Derived one-line title from a prompt's text; null for empty prompts. */
23
51
  export declare function deriveTitle(text: string): string | null;
@@ -28,6 +56,19 @@ export interface ContextAssemblyResult {
28
56
  /** True when the context-window estimate trimmed older messages. */
29
57
  readonly windowTrimmed: boolean;
30
58
  }
59
+ /**
60
+ * Resolve the regenerate pair: the last assistant message and THE user
61
+ * message it answers (turn association). Returns undefined when there is no
62
+ * assistant message at all; `user` is undefined when the turn association is
63
+ * broken — the user message immediately preceding the assistant is missing
64
+ * (deleted) or not the last row before it (an intervening message makes the
65
+ * association ambiguous), which the service rejects rather than silently
66
+ * regenerating an unrelated older prompt (review P1).
67
+ */
68
+ export declare function regenerateTarget(history: readonly ChatMessage[]): {
69
+ assistant: ChatMessage;
70
+ user: ChatMessage | undefined;
71
+ } | undefined;
31
72
  /**
32
73
  * Assemble the model-facing message list for one turn. System marker rows and
33
74
  * failed assistant messages are excluded; `contextMessages` (when set) keeps
@@ -35,5 +76,5 @@ export interface ContextAssemblyResult {
35
76
  * messages when the rough token count would overflow the model window.
36
77
  */
37
78
  export declare function assembleContext(history: readonly ChatMessage[], contextMessages: number | null, contextWindow: number | null): ContextAssemblyResult;
38
- /** Rough token estimate for one message's text blocks. */
79
+ /** Rough token estimate for one message's text and image blocks. */
39
80
  export declare function estimateTokens(message: ChatMessage): number;
package/lib/engine.js CHANGED
@@ -8,8 +8,19 @@
8
8
  export const TITLE_MAX_CHARS = 30;
9
9
  /** Rough token estimate (4 chars per token) for context-window trimming. */
10
10
  export const CHARS_PER_TOKEN = 4;
11
+ /**
12
+ * Rough token estimate for one image block: one token per 85×85 tile
13
+ * (the conventional vision-model pricing granularity), rounding up.
14
+ */
15
+ export const IMAGE_TILE_EDGE = 85;
11
16
  /** Context-window utilization kept under this fraction before trimming. */
12
17
  export const CONTEXT_WINDOW_SAFETY = 0.9;
18
+ /** Provenance for an assembled message derived from its chat log row. */
19
+ function sourceFor(role, message) {
20
+ return role === 'user'
21
+ ? { kind: 'user' }
22
+ : { kind: 'model', provider: message.provider ?? 'unknown', model: message.model ?? 'unknown' };
23
+ }
13
24
  /** Derived one-line title from a prompt's text; null for empty prompts. */
14
25
  export function deriveTitle(text) {
15
26
  const firstLine = text.replace(/\s+/g, ' ').trim();
@@ -22,6 +33,28 @@ export function deriveTitle(text) {
22
33
  function isUsable(message) {
23
34
  return message.role !== 'system' && message.error === undefined;
24
35
  }
36
+ /**
37
+ * Resolve the regenerate pair: the last assistant message and THE user
38
+ * message it answers (turn association). Returns undefined when there is no
39
+ * assistant message at all; `user` is undefined when the turn association is
40
+ * broken — the user message immediately preceding the assistant is missing
41
+ * (deleted) or not the last row before it (an intervening message makes the
42
+ * association ambiguous), which the service rejects rather than silently
43
+ * regenerating an unrelated older prompt (review P1).
44
+ */
45
+ export function regenerateTarget(history) {
46
+ for (let index = history.length - 1; index >= 0; index -= 1) {
47
+ const candidate = history[index];
48
+ if (candidate.role !== 'assistant')
49
+ continue;
50
+ const previous = history[index - 1];
51
+ if (previous !== undefined && previous.role === 'user') {
52
+ return { assistant: candidate, user: previous };
53
+ }
54
+ return { assistant: candidate, user: undefined };
55
+ }
56
+ return undefined;
57
+ }
25
58
  /**
26
59
  * Assemble the model-facing message list for one turn. System marker rows and
27
60
  * failed assistant messages are excluded; `contextMessages` (when set) keeps
@@ -56,16 +89,27 @@ export function assembleContext(history, contextMessages, contextWindow) {
56
89
  return {
57
90
  messages: selected.map((message) => ({
58
91
  role: message.role,
59
- content: message.blocks.map(block => ({ type: 'text', text: block.text })),
92
+ content: message.blocks.map(block => block.type === 'image'
93
+ ? { type: 'image', attachment: block.attachment }
94
+ : { type: 'text', text: block.text }),
95
+ source: sourceFor(message.role, message),
60
96
  })),
61
97
  contextMessages,
62
98
  windowTrimmed,
63
99
  };
64
100
  }
65
- /** Rough token estimate for one message's text blocks. */
101
+ /** Rough token estimate for one message's text and image blocks. */
66
102
  export function estimateTokens(message) {
67
- let chars = 0;
68
- for (const block of message.blocks)
69
- chars += block.text.length;
70
- return Math.ceil(chars / CHARS_PER_TOKEN);
103
+ let tokens = 0;
104
+ for (const block of message.blocks) {
105
+ if (block.type === 'image') {
106
+ const widthTiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE);
107
+ const heightTiles = Math.ceil(block.attachment.height / IMAGE_TILE_EDGE);
108
+ tokens += Math.max(1, widthTiles * heightTiles);
109
+ }
110
+ else {
111
+ tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN);
112
+ }
113
+ }
114
+ return tokens;
71
115
  }
package/lib/index.d.ts CHANGED
@@ -11,7 +11,7 @@ import { Context } from '@deepseek-ai/cordis';
11
11
  import z from '@deepseek-ai/schemastery';
12
12
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
13
13
  import { ChatStorageError } from './store.ts';
14
- import type { ChatCreateResult, ChatEmptyResult, ChatHistoryResult, ChatListResult, ChatSelectModelResult, ChatSendResult, ChatUpdateResult, ChatTextBlock } from './types.ts';
14
+ import type { ChatAttachmentResult, ChatCapabilitiesResult, ChatContentPart, ChatCreateResult, ChatDeleteMessageResult, ChatEmptyResult, ChatHistoryResult, ChatListResult, ChatRegenerateResult, ChatSelectModelResult, ChatSendResult, ChatUpdatePatch, ChatUpdateResult } from './types.ts';
15
15
  /** Stable Cordis plugin name. */
16
16
  export declare const name = "chat-service";
17
17
  declare module '@deepseek-ai/cordis' {
@@ -28,8 +28,8 @@ declare module '@deepseek-ai/cordis' {
28
28
  }
29
29
  /** Business failure carrying a stable code for diagnostics. */
30
30
  export declare class ChatServiceError extends Error {
31
- readonly code: 'no-model' | 'invalid-request' | 'unsupported-block';
32
- constructor(code: 'no-model' | 'invalid-request' | 'unsupported-block', message: string);
31
+ readonly code: 'no-model' | 'invalid-request' | 'unsupported-block' | 'image-unsupported';
32
+ constructor(code: 'no-model' | 'invalid-request' | 'unsupported-block' | 'image-unsupported', message: string);
33
33
  }
34
34
  /** Resolve the harness home the chat store lives under (env overrides default). */
35
35
  export declare function chatHomePath(): string;
@@ -70,19 +70,56 @@ export declare class ChatService extends TypertRemoteService {
70
70
  /**
71
71
  * Send one user turn into a conversation: the user message persists and is
72
72
  * anchored through `chat/message`, then its turn joins the FIFO queue.
73
+ * Image parts are admitted against the deployment attachment limits and
74
+ * promoted to durable references on the persisted message (design §3).
73
75
  */
74
- send(conversationId: string, content: readonly ChatTextBlock[]): Promise<ChatSendResult>;
76
+ send(conversationId: string, content: readonly ChatContentPart[]): Promise<ChatSendResult>;
77
+ /**
78
+ * Toggle a conversation's archive flag (M4). Archived conversations keep
79
+ * their messages and settings; the client hides them from the active view.
80
+ */
81
+ archive(conversationId: string, archived: boolean): Promise<ChatEmptyResult>;
82
+ /**
83
+ * Delete a single message (design §2.3): the host rewrites the message
84
+ * file, so subsequent context assembly excludes it. Queued sends bound to
85
+ * the deleted seq are dropped too; an in-flight turn keeps its assembled
86
+ * context and settles normally. Idempotent: absent seq reports removed:
87
+ * false without error.
88
+ */
89
+ deleteMessage(conversationId: string, seq: number): Promise<ChatDeleteMessageResult>;
90
+ /**
91
+ * Regenerate the last assistant message (design §3): remove it, then rerun
92
+ * the last user turn with the same context boundary. Rejected while a turn
93
+ * is running or when there is no assistant message to regenerate.
94
+ */
95
+ regenerate(conversationId: string): Promise<ChatRegenerateResult>;
96
+ /**
97
+ * Model capability probe for the conversation's current selection
98
+ * (adapter metadata; design §4.6): the client preflights image sending
99
+ * instead of discovering a refusal after the bytes were picked.
100
+ */
101
+ capabilities(conversationId: string): Promise<ChatCapabilitiesResult>;
102
+ /**
103
+ * Read one image's bytes after proving the conversation's message log
104
+ * references its attachment id (mirror of `session.attachment`'s
105
+ * authorization discipline; design §3 读图).
106
+ */
107
+ attachment(conversationId: string, attachmentId: string): Promise<ChatAttachmentResult>;
75
108
  /** Abort the in-flight turn (frozen partial is persisted); idempotent. */
76
109
  cancel(conversationId: string): Promise<ChatEmptyResult>;
77
110
  /** Switch the conversation's model selection. */
78
111
  selectModel(conversationId: string, provider: string, model: string): Promise<ChatSelectModelResult>;
79
112
  /** Update conversation settings; `null` in the patch unsets a field. */
80
- update(conversationId: string, patch: {
81
- readonly systemPrompt?: string | null;
82
- readonly temperature?: number | null;
83
- readonly contextMessages?: number | null;
84
- }): Promise<ChatUpdateResult>;
113
+ update(conversationId: string, patch: ChatUpdatePatch): Promise<ChatUpdateResult>;
85
114
  private defaultSelection;
115
+ /** Adapter metadata for the conversation's model (structural llm seam). */
116
+ private resolveModelInfo;
117
+ /**
118
+ * Whether the model accepts image input. Unknown capability is a refusal
119
+ * (fail-closed, mirror of the session image admission preflight outcome:
120
+ * an unresolvable adapter must not silently accept an image request).
121
+ */
122
+ private imageInputAllowed;
86
123
  private runnerFor;
87
124
  private turnRecord;
88
125
  /** Persist turn-start bookkeeping: shift the queued send and mark running. */