@prur/dsh-chat-service 0.1.14

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure conversation logic for the chat domain: title derivation, context
3
+ * assembly, and window trimming. No official runtime imports — unit tests run
4
+ * in this repo directly against these functions.
5
+ * @module @prur/dsh-chat-service/src/engine
6
+ */
7
+ import type { ChatMessage } from './types.ts';
8
+ /** Truncated title derived from the first user message. */
9
+ export declare const TITLE_MAX_CHARS = 30;
10
+ /** Rough token estimate (4 chars per token) for context-window trimming. */
11
+ export declare const CHARS_PER_TOKEN = 4;
12
+ /** Context-window utilization kept under this fraction before trimming. */
13
+ export declare const CONTEXT_WINDOW_SAFETY = 0.9;
14
+ /** One model-facing message produced by context assembly. */
15
+ export interface AssembledMessage {
16
+ readonly role: 'user' | 'assistant';
17
+ readonly content: readonly {
18
+ readonly type: 'text';
19
+ readonly text: string;
20
+ }[];
21
+ }
22
+ /** Derived one-line title from a prompt's text; null for empty prompts. */
23
+ export declare function deriveTitle(text: string): string | null;
24
+ /** The assembled model-facing message list for one turn. */
25
+ export interface ContextAssemblyResult {
26
+ readonly messages: readonly AssembledMessage[];
27
+ readonly contextMessages: number | null;
28
+ /** True when the context-window estimate trimmed older messages. */
29
+ readonly windowTrimmed: boolean;
30
+ }
31
+ /**
32
+ * Assemble the model-facing message list for one turn. System marker rows and
33
+ * failed assistant messages are excluded; `contextMessages` (when set) keeps
34
+ * only the newest that many messages; a `contextWindow` estimate drops older
35
+ * messages when the rough token count would overflow the model window.
36
+ */
37
+ export declare function assembleContext(history: readonly ChatMessage[], contextMessages: number | null, contextWindow: number | null): ContextAssemblyResult;
38
+ /** Rough token estimate for one message's text blocks. */
39
+ export declare function estimateTokens(message: ChatMessage): number;
package/lib/engine.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Pure conversation logic for the chat domain: title derivation, context
3
+ * assembly, and window trimming. No official runtime imports — unit tests run
4
+ * in this repo directly against these functions.
5
+ * @module @prur/dsh-chat-service/src/engine
6
+ */
7
+ /** Truncated title derived from the first user message. */
8
+ export const TITLE_MAX_CHARS = 30;
9
+ /** Rough token estimate (4 chars per token) for context-window trimming. */
10
+ export const CHARS_PER_TOKEN = 4;
11
+ /** Context-window utilization kept under this fraction before trimming. */
12
+ export const CONTEXT_WINDOW_SAFETY = 0.9;
13
+ /** Derived one-line title from a prompt's text; null for empty prompts. */
14
+ export function deriveTitle(text) {
15
+ const firstLine = text.replace(/\s+/g, ' ').trim();
16
+ if (firstLine.length === 0)
17
+ return null;
18
+ return firstLine.length <= TITLE_MAX_CHARS
19
+ ? firstLine
20
+ : `${firstLine.slice(0, TITLE_MAX_CHARS - 1)}…`;
21
+ }
22
+ function isUsable(message) {
23
+ return message.role !== 'system' && message.error === undefined;
24
+ }
25
+ /**
26
+ * Assemble the model-facing message list for one turn. System marker rows and
27
+ * failed assistant messages are excluded; `contextMessages` (when set) keeps
28
+ * only the newest that many messages; a `contextWindow` estimate drops older
29
+ * messages when the rough token count would overflow the model window.
30
+ */
31
+ export function assembleContext(history, contextMessages, contextWindow) {
32
+ const usable = history.filter(isUsable);
33
+ let selected = usable;
34
+ let windowTrimmed = false;
35
+ if (contextMessages !== null && selected.length > contextMessages) {
36
+ selected = selected.slice(-contextMessages);
37
+ }
38
+ if (contextWindow !== null && contextWindow > 0) {
39
+ const budget = Math.max(1, Math.floor(contextWindow * CONTEXT_WINDOW_SAFETY));
40
+ // Trim from the oldest while the rough token count overflows; the newest
41
+ // message is always kept (it is the prompt this turn answers).
42
+ let tokens = 0;
43
+ let kept = 0;
44
+ for (let index = selected.length - 1; index >= 0; index -= 1) {
45
+ const next = estimateTokens(selected[index]);
46
+ if (kept > 0 && tokens + next > budget)
47
+ break;
48
+ tokens += next;
49
+ kept += 1;
50
+ }
51
+ if (kept < selected.length) {
52
+ windowTrimmed = true;
53
+ selected = selected.slice(selected.length - kept);
54
+ }
55
+ }
56
+ return {
57
+ messages: selected.map((message) => ({
58
+ role: message.role,
59
+ content: message.blocks.map(block => ({ type: 'text', text: block.text })),
60
+ })),
61
+ contextMessages,
62
+ windowTrimmed,
63
+ };
64
+ }
65
+ /** Rough token estimate for one message's text blocks. */
66
+ 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);
71
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Plugin-owned chat event downlink: an in-process bus plus a WebSocket
3
+ * gateway at `/chat/events`, so clients stream chat frames through the
4
+ * plugin itself instead of the host's remote-event allowlist (which only
5
+ * forwards host-declared events). Frames use the SAME `server-request`
6
+ * envelope as `/api/events.host` (`{type: 'server-request', rpcId, method:
7
+ * 'host/remote-event', payload: {event, args}}`), so a client decodes them
8
+ * with its existing downlink frame parser unchanged.
9
+ * @module @prur/dsh-chat-service/src/events
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ /** The three chat events a client may receive on the downlink. */
13
+ export type ChatDownlinkEvent = 'chat/message' | 'chat/chunk' | 'chat/status';
14
+ /**
15
+ * Downlink frame: the full `server-request` envelope the DSH downlink
16
+ * transport uses (exact same shape as `/api/events.host` remote-event
17
+ * frames), so clients reuse their existing `ServerRequest` parse path.
18
+ */
19
+ export interface ChatDownlinkFrame {
20
+ readonly type: 'server-request';
21
+ readonly rpcId: string;
22
+ readonly method: 'host/remote-event';
23
+ readonly payload: {
24
+ readonly event: ChatDownlinkEvent;
25
+ readonly args: readonly unknown[];
26
+ };
27
+ }
28
+ /** Dedicated downstream WebSocket pathname (outside the /api prefix gate). */
29
+ export declare const CHAT_EVENTS_PATH = "/chat/events";
30
+ /**
31
+ * In-process event bus. The service publishes; the gateway subscribes and
32
+ * broadcasts to every connected client. Synchronous, listener failures are
33
+ * contained (one broken observer must not break a turn).
34
+ */
35
+ export declare class ChatEventBus {
36
+ private readonly listeners;
37
+ publish(event: ChatDownlinkEvent, args: readonly unknown[]): void;
38
+ subscribe(listener: (frame: ChatDownlinkFrame) => void): () => void;
39
+ }
40
+ /**
41
+ * WebSocket gateway for {@link CHAT_EVENTS_PATH}. The handshake passes the
42
+ * connection-mobile trust gate (bearer token / loopback fence) — the exact
43
+ * check the /api gate applies — so the chat channel inherits the deployment's
44
+ * authentication policy without duplicating it.
45
+ */
46
+ export declare class ChatEventsGateway {
47
+ private readonly ctx;
48
+ private readonly trustedHosts;
49
+ private readonly apiToken;
50
+ private readonly server;
51
+ private readonly clients;
52
+ private heartbeat;
53
+ constructor(ctx: Context, trustedHosts: readonly string[], apiToken: string | undefined);
54
+ /** Broadcast one frame to every connected client. */
55
+ broadcast(frame: ChatDownlinkFrame): void;
56
+ private handleUpgrade;
57
+ }
package/lib/events.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Plugin-owned chat event downlink: an in-process bus plus a WebSocket
3
+ * gateway at `/chat/events`, so clients stream chat frames through the
4
+ * plugin itself instead of the host's remote-event allowlist (which only
5
+ * forwards host-declared events). Frames use the SAME `server-request`
6
+ * envelope as `/api/events.host` (`{type: 'server-request', rpcId, method:
7
+ * 'host/remote-event', payload: {event, args}}`), so a client decodes them
8
+ * with its existing downlink frame parser unchanged.
9
+ * @module @prur/dsh-chat-service/src/events
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { isTrustedOrAuthorizedApiRequest, rejectWebSocketUpgrade, } from '@prur/dsh-client-connection-mobile';
13
+ import WebSocket, { WebSocketServer } from 'ws';
14
+ /** Dedicated downstream WebSocket pathname (outside the /api prefix gate). */
15
+ export const CHAT_EVENTS_PATH = '/chat/events';
16
+ /** Heartbeat interval; keeps proxies from dropping an idle downlink. */
17
+ const HEARTBEAT_INTERVAL_MS = 15_000;
18
+ /**
19
+ * In-process event bus. The service publishes; the gateway subscribes and
20
+ * broadcasts to every connected client. Synchronous, listener failures are
21
+ * contained (one broken observer must not break a turn).
22
+ */
23
+ export class ChatEventBus {
24
+ listeners = new Set();
25
+ publish(event, args) {
26
+ const frame = {
27
+ type: 'server-request',
28
+ rpcId: randomUUID(),
29
+ method: 'host/remote-event',
30
+ payload: { event, args },
31
+ };
32
+ for (const listener of [...this.listeners]) {
33
+ try {
34
+ listener(frame);
35
+ }
36
+ catch {
37
+ // A failed observer is contained; the turn continues.
38
+ }
39
+ }
40
+ }
41
+ subscribe(listener) {
42
+ this.listeners.add(listener);
43
+ return () => this.listeners.delete(listener);
44
+ }
45
+ }
46
+ /**
47
+ * WebSocket gateway for {@link CHAT_EVENTS_PATH}. The handshake passes the
48
+ * connection-mobile trust gate (bearer token / loopback fence) — the exact
49
+ * check the /api gate applies — so the chat channel inherits the deployment's
50
+ * authentication policy without duplicating it.
51
+ */
52
+ export class ChatEventsGateway {
53
+ ctx;
54
+ trustedHosts;
55
+ apiToken;
56
+ server = new WebSocketServer({ noServer: true });
57
+ clients = new Set();
58
+ heartbeat = null;
59
+ constructor(ctx, trustedHosts, apiToken) {
60
+ this.ctx = ctx;
61
+ this.trustedHosts = trustedHosts;
62
+ this.apiToken = apiToken;
63
+ this.ctx.effect(() => {
64
+ const dispose = this.ctx.webServer.registerUpgrade({
65
+ path: CHAT_EVENTS_PATH,
66
+ handler: (req, socket, head) => this.handleUpgrade(req, socket, head),
67
+ });
68
+ return () => {
69
+ dispose();
70
+ for (const client of this.clients)
71
+ client.terminate();
72
+ this.clients.clear();
73
+ this.server.close();
74
+ if (this.heartbeat !== null) {
75
+ clearInterval(this.heartbeat);
76
+ this.heartbeat = null;
77
+ }
78
+ };
79
+ }, '@prur/dsh-chat-service: /chat/events WebSocket');
80
+ }
81
+ /** Broadcast one frame to every connected client. */
82
+ broadcast(frame) {
83
+ const payload = JSON.stringify(frame);
84
+ for (const client of this.clients) {
85
+ if (client.readyState === WebSocket.OPEN)
86
+ client.send(payload);
87
+ }
88
+ }
89
+ handleUpgrade(req, socket, head) {
90
+ if (!isTrustedOrAuthorizedApiRequest(req, this.trustedHosts, this.apiToken, req.socket.localAddress)) {
91
+ rejectWebSocketUpgrade(socket);
92
+ return;
93
+ }
94
+ this.server.handleUpgrade(req, socket, head, (websocket) => {
95
+ this.clients.add(websocket);
96
+ const close = () => { this.clients.delete(websocket); };
97
+ websocket.on('close', close);
98
+ websocket.on('error', close);
99
+ // Downlink only: a client message is a protocol violation.
100
+ websocket.on('message', () => websocket.close(1008, 'downlink only'));
101
+ });
102
+ if (this.heartbeat === null) {
103
+ this.heartbeat = setInterval(() => {
104
+ for (const client of this.clients) {
105
+ if (client.readyState === WebSocket.OPEN)
106
+ client.ping();
107
+ }
108
+ }, HEARTBEAT_INTERVAL_MS);
109
+ this.heartbeat.unref?.();
110
+ }
111
+ // 认证边界:此通道以原生客户端(Bearer 头)为准;浏览器 dsh_token cookie 的
112
+ // Path=/api 不覆盖 /chat/events —— 移动端 SDK 一律带 Authorization 连入。
113
+ }
114
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * chat Typert Remote namespace: a plain streaming model chat domain
3
+ * (conversations stored under `$DSH_HOME/chat/`) that never enters the
4
+ * session/agent loop — no tools, no approvals, no workspace. Directly wired
5
+ * to `ctx.llm.stream`; progress is pushed through the plugin's own WebSocket
6
+ * downlink (`/chat/events`, see {@link ChatEventsGateway}) as
7
+ * `host/remote-event`-shaped frames, so no host code change is needed.
8
+ * @module @prur/dsh-chat-service
9
+ */
10
+ import { Context } from '@deepseek-ai/cordis';
11
+ import z from '@deepseek-ai/schemastery';
12
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
13
+ import { ChatStorageError } from './store.ts';
14
+ import type { ChatCreateResult, ChatEmptyResult, ChatHistoryResult, ChatListResult, ChatSelectModelResult, ChatSendResult, ChatUpdateResult, ChatTextBlock } from './types.ts';
15
+ /** Stable Cordis plugin name. */
16
+ export declare const name = "chat-service";
17
+ declare module '@deepseek-ai/cordis' {
18
+ interface Context {
19
+ chat: ChatService;
20
+ /** Optional host default-model config provided by the agent-default-model plugin. */
21
+ agentDefaultModel?: {
22
+ currentSelection(): {
23
+ provider: string;
24
+ model: string;
25
+ };
26
+ };
27
+ }
28
+ }
29
+ /** Business failure carrying a stable code for diagnostics. */
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);
33
+ }
34
+ /** Resolve the harness home the chat store lives under (env overrides default). */
35
+ export declare function chatHomePath(): string;
36
+ /** Composition config: the trust-gate facts mirroring connection-mobile's row. */
37
+ export interface ChatServiceConfig {
38
+ /** Non-loopback serving authorities, same fence semantics as connection-mobile. */
39
+ trustedHosts: string[];
40
+ /** Optional bearer token gating every non-loopback handshake. */
41
+ apiToken?: string;
42
+ }
43
+ /** Validate the composition config; defaults to an empty fence (loopback only). */
44
+ export declare const Config: z<ChatServiceConfig>;
45
+ /**
46
+ * Host face of the chat namespace: one service per host, conversations keyed
47
+ * by `conversationId`, turns serialized per conversation through
48
+ * {@link ChatTurnRunner}, push frames via {@link ChatEventsGateway}.
49
+ */
50
+ export declare class ChatService extends TypertRemoteService {
51
+ /** Required services: the LLM runtime and the HTTP carrier for the downlink. */
52
+ static inject: string[];
53
+ private readonly store;
54
+ private readonly runners;
55
+ private readonly bus;
56
+ private readonly gateway;
57
+ constructor(ctx: Context, config: ChatServiceConfig);
58
+ /** Re-run queued sends and settle half-finished conversations on startup. */
59
+ private recover;
60
+ /** List conversations newest-first with a running flag. */
61
+ list(): Promise<ChatListResult>;
62
+ /** Create a conversation; absent provider/model defaults to the host's current model. */
63
+ create(provider?: string, model?: string, title?: string): Promise<ChatCreateResult>;
64
+ /** Rename a conversation. */
65
+ rename(conversationId: string, title: string): Promise<ChatEmptyResult>;
66
+ /** Delete a conversation and its message log; idempotent. */
67
+ delete(conversationId: string): Promise<ChatEmptyResult>;
68
+ /** Page a conversation's history newest-first under the `beforeSeq` anchor. */
69
+ history(conversationId: string, beforeSeq?: number, maxMessages?: number): Promise<ChatHistoryResult>;
70
+ /**
71
+ * Send one user turn into a conversation: the user message persists and is
72
+ * anchored through `chat/message`, then its turn joins the FIFO queue.
73
+ */
74
+ send(conversationId: string, content: readonly ChatTextBlock[]): Promise<ChatSendResult>;
75
+ /** Abort the in-flight turn (frozen partial is persisted); idempotent. */
76
+ cancel(conversationId: string): Promise<ChatEmptyResult>;
77
+ /** Switch the conversation's model selection. */
78
+ selectModel(conversationId: string, provider: string, model: string): Promise<ChatSelectModelResult>;
79
+ /** 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>;
85
+ private defaultSelection;
86
+ private runnerFor;
87
+ private turnRecord;
88
+ /** Persist turn-start bookkeeping: shift the queued send and mark running. */
89
+ private turnStart;
90
+ /** Persist turn-end bookkeeping: release the running flag. */
91
+ private turnEnd;
92
+ private emitMessage;
93
+ private emitStatus;
94
+ }
95
+ export default ChatService;
96
+ export { ChatStorageError };