@prur/dsh-chat-service 0.1.14 → 0.1.16

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/store.d.ts CHANGED
@@ -10,12 +10,13 @@
10
10
  * append cannot corrupt a log.
11
11
  * @module @prur/dsh-chat-service/src/store
12
12
  */
13
- import type { ChatMessage, ChatTextBlock } from './types.ts';
13
+ import type { ChatBlock, ChatMessage } from './types.ts';
14
14
  /** One still-queued user send (content persisted before the turn runs). */
15
15
  export interface PendingSend {
16
16
  /** Persisted user message seq this send belongs to — the turn's context boundary. */
17
17
  readonly seq: number;
18
- readonly content: readonly ChatTextBlock[];
18
+ /** Persisted blocks of the user message (durable refs; no upload bytes). */
19
+ readonly content: readonly ChatBlock[];
19
20
  readonly createdAt: number;
20
21
  }
21
22
  /** Durable per-conversation record, held in `index.json`. */
@@ -32,6 +33,14 @@ export interface ChatIndexRecord {
32
33
  readonly queue: readonly PendingSend[];
33
34
  /** Mirror of the live run state, persisted so restart recovery can resume the queue. */
34
35
  readonly running: boolean;
36
+ /** Archive flag (M4); legacy records treat absence as false. */
37
+ readonly archived?: boolean;
38
+ /**
39
+ * Durable monotonic sequence high-water mark (never reused — even when a
40
+ * message is deleted, the next allocated seq keeps advancing). Legacy
41
+ * records without the field normalize from their log on first use.
42
+ */
43
+ readonly seqHighWater: number;
35
44
  }
36
45
  /** Storage failure raised by a missing conversation; mapped to an RPC error. */
37
46
  export declare class ChatStorageError extends Error {
@@ -62,18 +71,64 @@ export declare class ChatStore {
62
71
  /** @throws {@link ChatStorageError} when the conversation does not exist. */
63
72
  require(conversationId: string): Promise<ChatIndexRecord>;
64
73
  put(record: ChatIndexRecord): Promise<void>;
74
+ /**
75
+ * Atomic read-modify-write of one conversation record: the read and the
76
+ * write happen inside one store-wide write lock, so a concurrent queue
77
+ * append / flag flip cannot be lost to an outdated snapshot (the
78
+ * require-then-put pattern is a lost-update hazard under concurrent
79
+ * `chat/send` / `chat/regenerate` / turn bookkeeping).
80
+ * @throws {@link ChatStorageError} when the conversation does not exist.
81
+ */
82
+ mutateRecord(conversationId: string, mutate: (record: ChatIndexRecord) => ChatIndexRecord): Promise<ChatIndexRecord>;
65
83
  /** Delete a conversation and its log; idempotent. */
66
84
  remove(conversationId: string): Promise<void>;
67
85
  readMessages(conversationId: string): Promise<ChatMessage[]>;
86
+ /**
87
+ * Serialize one combined conversation mutation (read record + messages,
88
+ * write index, optionally rewrite the log) under the store-wide write
89
+ * lock. Competing tail mutations (regenerate / send / deleteMessage) are
90
+ * serialized at this boundary, so a second regenerate cannot double-enqueue
91
+ * the same turn against a stale view (review P1).
92
+ */
93
+ mutateConversation<T>(conversationId: string, mutate: (record: ChatIndexRecord, messages: ChatMessage[]) => Promise<{
94
+ nextRecord: ChatIndexRecord;
95
+ nextMessages?: ChatMessage[];
96
+ result: T;
97
+ }>): Promise<T>;
98
+ /**
99
+ * Allocate the next monotonic message seq from the record high-water mark
100
+ * and persist the bump atomically. Sequence identities are never reused —
101
+ * even after a message is deleted, the next allocation keeps advancing
102
+ * (a crash between this bump and the log append only skips a seq).
103
+ */
104
+ reserveTurnSeq(conversationId: string): Promise<number>;
68
105
  /**
69
106
  * Append one message under the write lock and maintain the per-conversation
70
107
  * cap: when the cap would be exceeded, the oldest messages are dropped and
71
108
  * a system marker row is appended in their place (client renders a notice).
109
+ * The marker seq is a fresh high-water value (above every real seq), so a
110
+ * trimmed conversation keeps clean monotonic anchors.
72
111
  * @returns whether trimming occurred.
73
112
  */
74
113
  appendMessage(conversationId: string, message: ChatMessage): Promise<{
75
114
  trimmed: boolean;
76
115
  }>;
116
+ /**
117
+ * Normalize legacy records without `seqHighWater`: persist the log's max
118
+ * seq so regeneration and future allocations keep monotonic identity.
119
+ */
120
+ normalizeSeqHighWaters(): Promise<void>;
121
+ /**
122
+ * Rewrite the log without one message (design §2.3: host rewrites the
123
+ * message file; subsequent context assembly recalculates). Missing seq is
124
+ * reported via `removed: false` (idempotent). Never touches the running
125
+ * flag or the queue — queued sends bound to the deleted seq are dropped by
126
+ * the caller.
127
+ * @returns whether a message with `seq` was present and removed.
128
+ */
129
+ deleteMessage(conversationId: string, seq: number): Promise<{
130
+ removed: boolean;
131
+ }>;
77
132
  /**
78
133
  * Page history newest-first paging semantics like `session.history`: the
79
134
  * tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
package/lib/store.js CHANGED
@@ -26,6 +26,15 @@ export class ChatStorageError extends Error {
26
26
  export const CHAT_MESSAGE_CAP = 500;
27
27
  /** Marker row inserted after trimming: keeps the timeline readable for clients. */
28
28
  export const CHAT_TRIM_MARKER_TEXT = '上下文已裁剪';
29
+ /** Next monotonic message sequence from a history's max seq (legacy path). */
30
+ function nextSeqOf(messages) {
31
+ let last = 0;
32
+ for (const message of messages) {
33
+ if (message.seq > last)
34
+ last = message.seq;
35
+ }
36
+ return last + 1;
37
+ }
29
38
  /** conversationId charset guard: ids are opaque tokens, never path syntax. */
30
39
  const CONVERSATION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
31
40
  /** Whether a candidate conversation id is safe to use as a filename stem. */
@@ -62,6 +71,12 @@ export class ChatStore {
62
71
  return join(this.chatHome, 'index.json');
63
72
  }
64
73
  logPath(conversationId) {
74
+ // 纵深防御(安全审计 2026-08-26):RPC 边界的 zod regex 是唯一闸门——若未来
75
+ // 新增 @Remote 路径漏用该 codec,join() 会直接逃逸 $DSH_HOME/chat。store 层
76
+ // 重校验一次,把"路径安全"从不变量化边界提前到每个文件操作。
77
+ if (!isSafeConversationId(conversationId)) {
78
+ throw new ChatStorageError('corrupt', `chat store: unsafe conversationId ${JSON.stringify(conversationId)}`);
79
+ }
65
80
  return join(this.chatHome, `${conversationId}.jsonl`);
66
81
  }
67
82
  /** Serialize every mutation: one store-wide chain, errors contained per op. */
@@ -98,6 +113,27 @@ export class ChatStore {
98
113
  await writeFileAtomic(this.indexPath(), JSON.stringify(next, null, 2));
99
114
  });
100
115
  }
116
+ /**
117
+ * Atomic read-modify-write of one conversation record: the read and the
118
+ * write happen inside one store-wide write lock, so a concurrent queue
119
+ * append / flag flip cannot be lost to an outdated snapshot (the
120
+ * require-then-put pattern is a lost-update hazard under concurrent
121
+ * `chat/send` / `chat/regenerate` / turn bookkeeping).
122
+ * @throws {@link ChatStorageError} when the conversation does not exist.
123
+ */
124
+ async mutateRecord(conversationId, mutate) {
125
+ return this.withWriteLock(async () => {
126
+ const records = await readJson(this.indexPath(), []);
127
+ const index = records.findIndex(record => record.conversationId === conversationId);
128
+ if (index < 0) {
129
+ throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
130
+ }
131
+ const next = mutate(records[index]);
132
+ records[index] = next;
133
+ await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
134
+ return next;
135
+ });
136
+ }
101
137
  /** Delete a conversation and its log; idempotent. */
102
138
  async remove(conversationId) {
103
139
  return this.withWriteLock(async () => {
@@ -131,15 +167,72 @@ export class ChatStore {
131
167
  throw new ChatStorageError('io', `chat: cannot read log: ${error.message}`);
132
168
  }
133
169
  }
170
+ /**
171
+ * Serialize one combined conversation mutation (read record + messages,
172
+ * write index, optionally rewrite the log) under the store-wide write
173
+ * lock. Competing tail mutations (regenerate / send / deleteMessage) are
174
+ * serialized at this boundary, so a second regenerate cannot double-enqueue
175
+ * the same turn against a stale view (review P1).
176
+ */
177
+ async mutateConversation(conversationId, mutate) {
178
+ return this.withWriteLock(async () => {
179
+ const records = await readJson(this.indexPath(), []);
180
+ const index = records.findIndex(record => record.conversationId === conversationId);
181
+ if (index < 0) {
182
+ throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
183
+ }
184
+ const messages = await this.readMessages(conversationId);
185
+ const { nextRecord, nextMessages, result } = await mutate(records[index], messages);
186
+ records[index] = nextRecord;
187
+ await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
188
+ if (nextMessages !== undefined) {
189
+ await writeFileAtomic(this.logPath(conversationId), `${nextMessages.map(item => JSON.stringify(item)).join('\n')}\n`);
190
+ }
191
+ return result;
192
+ });
193
+ }
194
+ /**
195
+ * Allocate the next monotonic message seq from the record high-water mark
196
+ * and persist the bump atomically. Sequence identities are never reused —
197
+ * even after a message is deleted, the next allocation keeps advancing
198
+ * (a crash between this bump and the log append only skips a seq).
199
+ */
200
+ async reserveTurnSeq(conversationId) {
201
+ return this.withWriteLock(async () => {
202
+ await this.ensure();
203
+ const records = await readJson(this.indexPath(), []);
204
+ const index = records.findIndex(record => record.conversationId === conversationId);
205
+ if (index < 0) {
206
+ throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
207
+ }
208
+ const record = records[index];
209
+ const messages = await this.readMessages(conversationId);
210
+ const next = (record.seqHighWater ?? nextSeqOf(messages)) + 1;
211
+ records[index] = { ...record, seqHighWater: next };
212
+ await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
213
+ return next;
214
+ });
215
+ }
134
216
  /**
135
217
  * Append one message under the write lock and maintain the per-conversation
136
218
  * cap: when the cap would be exceeded, the oldest messages are dropped and
137
219
  * a system marker row is appended in their place (client renders a notice).
220
+ * The marker seq is a fresh high-water value (above every real seq), so a
221
+ * trimmed conversation keeps clean monotonic anchors.
138
222
  * @returns whether trimming occurred.
139
223
  */
140
224
  async appendMessage(conversationId, message) {
141
225
  return this.withWriteLock(async () => {
142
226
  await this.ensure();
227
+ // 锁内核对 index 存在性:对话已删除时其日志必须一起死亡——并发
228
+ // chat/delete 与 send 的竞态若绕开此检查,会按旧路径复活一个孤儿日志
229
+ // (index 是唯一事实源;孤儿文件客户端不可见,但会永久残留)。
230
+ const records = await readJson(this.indexPath(), []);
231
+ const index = records.findIndex(item => item.conversationId === conversationId);
232
+ if (index < 0) {
233
+ throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
234
+ }
235
+ const record = records[index];
143
236
  const messages = await this.readMessages(conversationId);
144
237
  messages.push(message);
145
238
  const excess = messages.length - CHAT_MESSAGE_CAP;
@@ -150,20 +243,65 @@ export class ChatStore {
150
243
  // One marker at a time: drop every existing system row, then trim the
151
244
  // oldest real rows to the cap minus the marker slot, then append the
152
245
  // fresh marker (a notice at the trim boundary, not a per-append flood).
153
- const real = messages.filter(message => message.role !== 'system');
246
+ const real = messages.filter(item => item.role !== 'system');
154
247
  const kept = real.slice(real.length - (CHAT_MESSAGE_CAP - 1));
248
+ const markerSeq = (record.seqHighWater ?? nextSeqOf(messages)) + 1;
155
249
  const marker = {
156
- seq: message.seq + excess,
250
+ seq: markerSeq,
157
251
  role: 'system',
158
252
  blocks: [{ type: 'text', text: CHAT_TRIM_MARKER_TEXT }],
159
253
  createdAt: Date.now(),
160
254
  };
255
+ records[index] = { ...record, seqHighWater: markerSeq };
256
+ await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
161
257
  next = [...kept, marker];
162
258
  }
163
259
  await writeFileAtomic(this.logPath(conversationId), `${next.map(item => JSON.stringify(item)).join('\n')}\n`);
164
260
  return { trimmed };
165
261
  });
166
262
  }
263
+ /**
264
+ * Normalize legacy records without `seqHighWater`: persist the log's max
265
+ * seq so regeneration and future allocations keep monotonic identity.
266
+ */
267
+ async normalizeSeqHighWaters() {
268
+ return this.withWriteLock(async () => {
269
+ const records = await readJson(this.indexPath(), []);
270
+ let changed = false;
271
+ for (const record of records) {
272
+ if (record.seqHighWater === undefined) {
273
+ const messages = await this.readMessages(record.conversationId);
274
+ records[records.findIndex(item => item.conversationId === record.conversationId)] = {
275
+ ...record,
276
+ seqHighWater: nextSeqOf(messages),
277
+ archived: record.archived === true,
278
+ };
279
+ changed = true;
280
+ }
281
+ }
282
+ if (changed) {
283
+ await writeFileAtomic(this.indexPath(), JSON.stringify(records, null, 2));
284
+ }
285
+ });
286
+ }
287
+ /**
288
+ * Rewrite the log without one message (design §2.3: host rewrites the
289
+ * message file; subsequent context assembly recalculates). Missing seq is
290
+ * reported via `removed: false` (idempotent). Never touches the running
291
+ * flag or the queue — queued sends bound to the deleted seq are dropped by
292
+ * the caller.
293
+ * @returns whether a message with `seq` was present and removed.
294
+ */
295
+ async deleteMessage(conversationId, seq) {
296
+ return this.withWriteLock(async () => {
297
+ const messages = await this.readMessages(conversationId);
298
+ const next = messages.filter(message => message.seq !== seq);
299
+ if (next.length === messages.length)
300
+ return { removed: false };
301
+ await writeFileAtomic(this.logPath(conversationId), `${next.map(item => JSON.stringify(item)).join('\n')}\n`);
302
+ return { removed: true };
303
+ });
304
+ }
167
305
  /**
168
306
  * Page history newest-first paging semantics like `session.history`: the
169
307
  * tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
@@ -16,10 +16,23 @@ const textBlockSchema = z.object({
16
16
  type: z.literal('text'),
17
17
  text: z.string(),
18
18
  }).readonly();
19
+ const imageRefSchema = z.object({
20
+ attachmentId: z.string().min(1),
21
+ mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
22
+ bytes: z.number().int().nonnegative(),
23
+ width: z.number().int().positive(),
24
+ height: z.number().int().positive(),
25
+ name: z.string().optional(),
26
+ }).readonly();
27
+ const imageBlockSchema = z.object({
28
+ type: z.literal('image'),
29
+ attachment: imageRefSchema,
30
+ }).readonly();
31
+ const blockSchema = z.union([textBlockSchema, imageBlockSchema]);
19
32
  const messageSchema = z.object({
20
33
  seq: z.number().int().nonnegative(),
21
34
  role: z.enum(['user', 'assistant', 'system']),
22
- blocks: z.array(textBlockSchema).readonly(),
35
+ blocks: z.array(blockSchema).readonly(),
23
36
  model: z.string().optional(),
24
37
  provider: z.string().optional(),
25
38
  createdAt: z.number().int().nonnegative().optional(),
@@ -33,6 +46,7 @@ const summarySchema = z.object({
33
46
  updatedAt: z.number().int().nonnegative(),
34
47
  messageCount: z.number().int().nonnegative(),
35
48
  running: z.boolean(),
49
+ archived: z.boolean(),
36
50
  }).readonly();
37
51
  const settingsSchema = z.object({
38
52
  conversationId: conversationIdSchema,
@@ -49,7 +63,13 @@ const patchSchema = z.object({
49
63
  temperature: z.number().nullable().optional(),
50
64
  contextMessages: z.number().int().positive().nullable().optional(),
51
65
  }).readonly();
52
- const contentSchema = z.array(textBlockSchema).min(1).readonly();
66
+ const imageUploadSchema = z.object({
67
+ type: z.literal('image'),
68
+ mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
69
+ data: z.string().min(1),
70
+ name: z.string().optional(),
71
+ }).readonly();
72
+ const contentSchema = z.array(z.union([textBlockSchema, imageUploadSchema])).min(1).readonly();
53
73
  const emptyOkSchema = z.object({}).readonly();
54
74
  const listResultSchema = z.object({
55
75
  ok: z.literal(true),
@@ -82,6 +102,27 @@ const updateResultSchema = z.object({
82
102
  ok: z.literal(true),
83
103
  value: settingsSchema,
84
104
  }).readonly();
105
+ const deleteMessageResultSchema = z.object({
106
+ ok: z.literal(true),
107
+ value: z.object({ removed: z.boolean() }),
108
+ }).readonly();
109
+ const regenerateResultSchema = sendResultSchema;
110
+ const capabilitiesResultSchema = z.object({
111
+ ok: z.literal(true),
112
+ value: z.object({
113
+ provider: z.string(),
114
+ model: z.string(),
115
+ imageInput: z.boolean(),
116
+ contextWindow: z.number().int().positive().nullable(),
117
+ }),
118
+ }).readonly();
119
+ const attachmentResultSchema = z.object({
120
+ ok: z.literal(true),
121
+ value: z.object({
122
+ attachment: imageRefSchema,
123
+ data: z.string(),
124
+ }),
125
+ }).readonly();
85
126
  const RESULT_TYPE_SYMBOLS = {
86
127
  list: '@prur/dsh-chat-service/types#ChatListResult',
87
128
  create: '@prur/dsh-chat-service/types#ChatCreateResult',
@@ -92,6 +133,11 @@ const RESULT_TYPE_SYMBOLS = {
92
133
  cancel: '@prur/dsh-chat-service/types#ChatEmptyResult',
93
134
  selectModel: '@prur/dsh-chat-service/types#ChatSelectModelResult',
94
135
  update: '@prur/dsh-chat-service/types#ChatUpdateResult',
136
+ deleteMessage: '@prur/dsh-chat-service/types#ChatDeleteMessageResult',
137
+ regenerate: '@prur/dsh-chat-service/types#ChatRegenerateResult',
138
+ capabilities: '@prur/dsh-chat-service/types#ChatCapabilitiesResult',
139
+ attachment: '@prur/dsh-chat-service/types#ChatAttachmentResult',
140
+ archive: '@prur/dsh-chat-service/types#ChatEmptyResult',
95
141
  };
96
142
  const invocation = (id, method, parameters, result) => ({
97
143
  id: `@prur/dsh-chat-service#${id}`,
@@ -131,7 +177,7 @@ export const TYPERT = {
131
177
  ], historyResultSchema),
132
178
  invocation('chat/send', 'send', [
133
179
  { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
134
- { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#ChatTextBlock[]', schema: contentSchema } },
180
+ { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#ChatContentPart[]', schema: contentSchema } },
135
181
  ], sendResultSchema),
136
182
  invocation('chat/cancel', 'cancel', [
137
183
  { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
@@ -145,6 +191,24 @@ export const TYPERT = {
145
191
  { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
146
192
  { name: 'patch', wire: 'patch', source: 'json', codec: { mode: 'strict', typeSymbol: '@prur/dsh-chat-service/types#ChatUpdatePatch', schema: patchSchema } },
147
193
  ], updateResultSchema),
194
+ invocation('chat/archive', 'archive', [
195
+ { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
196
+ { name: 'archived', wire: 'archived', source: 'json', codec: { mode: 'strict', typeSymbol: 'boolean', schema: z.boolean() } },
197
+ ], emptyResultSchema),
198
+ invocation('chat/deleteMessage', 'deleteMessage', [
199
+ { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
200
+ { name: 'seq', wire: 'seq', source: 'json', codec: { mode: 'strict', typeSymbol: 'number', schema: z.number().int().nonnegative() } },
201
+ ], deleteMessageResultSchema),
202
+ invocation('chat/regenerate', 'regenerate', [
203
+ { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
204
+ ], regenerateResultSchema),
205
+ invocation('chat/capabilities', 'capabilities', [
206
+ { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
207
+ ], capabilitiesResultSchema),
208
+ invocation('chat/attachment', 'attachment', [
209
+ { name: 'conversationId', wire: 'conversationId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: conversationIdSchema } },
210
+ { name: 'attachmentId', wire: 'attachmentId', source: 'json', codec: { mode: 'strict', typeSymbol: 'string', schema: z.string().min(1) } },
211
+ ], attachmentResultSchema),
148
212
  ],
149
213
  model: {
150
214
  services: [],
@@ -15,6 +15,11 @@ const RESULT_TYPE_SYMBOLS = {
15
15
  cancel: '@prur/dsh-chat-service/types#ChatEmptyResult',
16
16
  selectModel: '@prur/dsh-chat-service/types#ChatSelectModelResult',
17
17
  update: '@prur/dsh-chat-service/types#ChatUpdateResult',
18
+ deleteMessage: '@prur/dsh-chat-service/types#ChatDeleteMessageResult',
19
+ regenerate: '@prur/dsh-chat-service/types#ChatRegenerateResult',
20
+ capabilities: '@prur/dsh-chat-service/types#ChatCapabilitiesResult',
21
+ attachment: '@prur/dsh-chat-service/types#ChatAttachmentResult',
22
+ archive: '@prur/dsh-chat-service/types#ChatEmptyResult',
18
23
  };
19
24
  const descriptor = (id, method, parameters) => ({
20
25
  id: `@prur/dsh-chat-service#${id}`,
@@ -65,6 +70,24 @@ export const TYPERT_REMOTE = {
65
70
  { name: 'conversationId', wire: 'conversationId', source: 'json' },
66
71
  { name: 'patch', wire: 'patch', source: 'json' },
67
72
  ]),
73
+ descriptor('chat/archive', 'archive', [
74
+ { name: 'conversationId', wire: 'conversationId', source: 'json' },
75
+ { name: 'archived', wire: 'archived', source: 'json' },
76
+ ]),
77
+ descriptor('chat/deleteMessage', 'deleteMessage', [
78
+ { name: 'conversationId', wire: 'conversationId', source: 'json' },
79
+ { name: 'seq', wire: 'seq', source: 'json' },
80
+ ]),
81
+ descriptor('chat/regenerate', 'regenerate', [
82
+ { name: 'conversationId', wire: 'conversationId', source: 'json' },
83
+ ]),
84
+ descriptor('chat/capabilities', 'capabilities', [
85
+ { name: 'conversationId', wire: 'conversationId', source: 'json' },
86
+ ]),
87
+ descriptor('chat/attachment', 'attachment', [
88
+ { name: 'conversationId', wire: 'conversationId', source: 'json' },
89
+ { name: 'attachmentId', wire: 'attachmentId', source: 'json' },
90
+ ]),
68
91
  ],
69
92
  };
70
93
  export default TYPERT_REMOTE;
package/lib/types.d.ts CHANGED
@@ -4,11 +4,43 @@
4
4
  * shape lives here; the DSH-Mobile SDK mirrors it in `ChatRemoteClient`.
5
5
  * @module @prur/dsh-chat-service/src/types
6
6
  */
7
- /** One text content block (v1 keeps chat text-only; image blocks arrive with M3). */
7
+ /** One text content block. */
8
8
  export interface ChatTextBlock {
9
9
  readonly type: 'text';
10
10
  readonly text: string;
11
11
  }
12
+ /**
13
+ * A durable raster image reference carried by a persisted chat message.
14
+ * The bytes live in the harness attachment store; clients fetch them through
15
+ * `chat/attachment` after proving the conversation references the id
16
+ * (`chat/history` / `chat/message` events carry this ref in the block).
17
+ */
18
+ export interface ChatImageAttachmentRef {
19
+ readonly attachmentId: string;
20
+ readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
21
+ readonly bytes: number;
22
+ readonly width: number;
23
+ readonly height: number;
24
+ readonly name?: string;
25
+ }
26
+ /** One image content block as persisted on the wire (ref without bytes). */
27
+ export interface ChatImageBlock {
28
+ readonly type: 'image';
29
+ readonly attachment: ChatImageAttachmentRef;
30
+ }
31
+ /**
32
+ * One client-submitted content part for `chat/send`: a text span, or a
33
+ * base64 image upload admitted against the deployment's attachment limits
34
+ * and promoted to a durable {@link ChatImageBlock} on the message.
35
+ */
36
+ export type ChatContentPart = ChatTextBlock | {
37
+ readonly type: 'image';
38
+ readonly mediaType: ChatImageAttachmentRef['mediaType'];
39
+ readonly data: string;
40
+ readonly name?: string;
41
+ };
42
+ /** One chat message content block persisted on the wire. */
43
+ export type ChatBlock = ChatTextBlock | ChatImageBlock;
12
44
  /** Reason a running turn settled, carried by the `chat/status` event. */
13
45
  export type ChatTurnReason = 'completed' | 'aborted' | 'error' | 'max-tokens' | 'context-trimmed';
14
46
  /**
@@ -18,7 +50,7 @@ export type ChatTurnReason = 'completed' | 'aborted' | 'error' | 'max-tokens' |
18
50
  export interface ChatMessage {
19
51
  readonly seq: number;
20
52
  readonly role: 'user' | 'assistant' | 'system';
21
- readonly blocks: readonly ChatTextBlock[];
53
+ readonly blocks: readonly ChatBlock[];
22
54
  /** Provider route that produced an assistant message. */
23
55
  readonly model?: string;
24
56
  /** Provider route key that produced an assistant message. */
@@ -45,6 +77,8 @@ export interface ChatConversationSummary {
45
77
  readonly updatedAt: number;
46
78
  readonly messageCount: number;
47
79
  readonly running: boolean;
80
+ /** Archived conversations stay in the store but drop out of the active view. */
81
+ readonly archived: boolean;
48
82
  }
49
83
  /** Settings view of one conversation (returned by `chat/update`). */
50
84
  export interface ChatConversationSettings {
@@ -57,6 +91,15 @@ export interface ChatConversationSettings {
57
91
  readonly systemPrompt: string | null;
58
92
  readonly updatedAt: number;
59
93
  }
94
+ /**
95
+ * One `chat/update` field patch: `undefined` keeps the current value,
96
+ * `null` unsets it.
97
+ */
98
+ export interface ChatUpdatePatch {
99
+ readonly systemPrompt?: string | null;
100
+ readonly temperature?: number | null;
101
+ readonly contextMessages?: number | null;
102
+ }
60
103
  /** Successful chats of `chat/list`. */
61
104
  export type ChatListResult = {
62
105
  readonly ok: true;
@@ -104,3 +147,38 @@ export type ChatUpdateResult = {
104
147
  readonly ok: true;
105
148
  readonly value: ChatConversationSettings;
106
149
  };
150
+ /** Successful value of `chat/deleteMessage` (idempotent: removed or absent). */
151
+ export type ChatDeleteMessageResult = {
152
+ readonly ok: true;
153
+ readonly value: {
154
+ readonly removed: boolean;
155
+ };
156
+ };
157
+ /** Successful value of `chat/regenerate`. */
158
+ export type ChatRegenerateResult = {
159
+ readonly ok: true;
160
+ readonly value: {
161
+ readonly accepted: true;
162
+ };
163
+ };
164
+ /** Successful value of `chat/capabilities`. */
165
+ export type ChatCapabilitiesResult = {
166
+ readonly ok: true;
167
+ readonly value: {
168
+ readonly provider: string;
169
+ readonly model: string;
170
+ /** Whether the model accepts image input (adapter `inputModalities`). */
171
+ readonly imageInput: boolean;
172
+ /** Advertised context window in tokens; null when unknown. */
173
+ readonly contextWindow: number | null;
174
+ };
175
+ };
176
+ /** Successful value of `chat/attachment`. */
177
+ export type ChatAttachmentResult = {
178
+ readonly ok: true;
179
+ readonly value: {
180
+ readonly attachment: ChatImageAttachmentRef;
181
+ /** Canonical base64 encoding of the image bytes. */
182
+ readonly data: string;
183
+ };
184
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prur/dsh-chat-service",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Typert Remote namespace exposing a plain streaming model chat (no agent loop) to DSH Mobile clients",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,17 +32,18 @@
32
32
  },
33
33
  "dsh": null,
34
34
  "dependencies": {
35
- "zod": "^4.4.3",
36
35
  "@deepseek-ai/schemastery": "^3.18.1",
37
36
  "ws": "^8.21.0",
38
- "@prur/dsh-client-connection-mobile": "^0.1.14"
37
+ "zod": "^4.4.3",
38
+ "@prur/dsh-client-connection-mobile": "^0.1.16"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@deepseek-ai/cordis": "^4.0.1",
42
+ "@deepseek-ai/dsh-attachment": "^0.1.0-rc.8 || ^0.1.1-rc.2",
43
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.2",
42
44
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8 || ^0.1.1-rc.2",
43
45
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.8 || ^0.1.1-rc.2",
44
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8 || ^0.1.1-rc.2",
45
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.2"
46
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8 || ^0.1.1-rc.2"
46
47
  },
47
48
  "files": [
48
49
  "lib/*.js",