@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 +21 -0
- package/lib/engine.d.ts +39 -0
- package/lib/engine.js +71 -0
- package/lib/events.d.ts +57 -0
- package/lib/events.js +114 -0
- package/lib/index.d.ts +96 -0
- package/lib/index.js +379 -0
- package/lib/runner.d.ts +102 -0
- package/lib/runner.js +222 -0
- package/lib/store.d.ts +88 -0
- package/lib/store.js +185 -0
- package/lib/typert.host.d.ts +50 -0
- package/lib/typert.host.js +155 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/typert.remote-client.js +70 -0
- package/lib/types.d.ts +106 -0
- package/lib/types.js +7 -0
- package/package.json +55 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming turn runner for one chat conversation: FIFO queue, one turn at a
|
|
3
|
+
* time, chunk event emission, cancel/abort settlement, and window-trim
|
|
4
|
+
* signaling. The llm seam is structural (`LlmLike`) so unit tests run with a
|
|
5
|
+
* fake stream in this repo; the live service wires `ctx.llm` in.
|
|
6
|
+
* @module @prur/dsh-chat-service/src/runner
|
|
7
|
+
*/
|
|
8
|
+
import { assembleContext } from './engine.js';
|
|
9
|
+
function isAbortError(error) {
|
|
10
|
+
if (error instanceof Error && error.name === 'AbortError')
|
|
11
|
+
return true;
|
|
12
|
+
const cause = error?.cause;
|
|
13
|
+
return cause instanceof Error && cause.name === 'AbortError';
|
|
14
|
+
}
|
|
15
|
+
function mapFinishKind(kind) {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case 'aborted': return 'aborted';
|
|
18
|
+
case 'error': return 'error';
|
|
19
|
+
case 'max-tokens': return 'max-tokens';
|
|
20
|
+
default: return 'completed';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Next monotonic message sequence for a conversation's persisted history. */
|
|
24
|
+
export function nextSeq(messages) {
|
|
25
|
+
let last = 0;
|
|
26
|
+
for (const message of messages) {
|
|
27
|
+
if (message.seq > last)
|
|
28
|
+
last = message.seq;
|
|
29
|
+
}
|
|
30
|
+
return last + 1;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Serialized per-conversation turn machine: sends enqueue; the queue drains
|
|
34
|
+
* one turn at a time; cancel aborts only the in-flight turn.
|
|
35
|
+
*/
|
|
36
|
+
export class ChatTurnRunner {
|
|
37
|
+
conversationId;
|
|
38
|
+
deps;
|
|
39
|
+
queue = [];
|
|
40
|
+
running = false;
|
|
41
|
+
controller = null;
|
|
42
|
+
disposed = false;
|
|
43
|
+
constructor(conversationId, deps) {
|
|
44
|
+
this.conversationId = conversationId;
|
|
45
|
+
this.deps = deps;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a turn is currently streaming. */
|
|
48
|
+
isRunning() {
|
|
49
|
+
return this.running;
|
|
50
|
+
}
|
|
51
|
+
/** Enqueue a user send and start draining if idle. */
|
|
52
|
+
enqueue(send) {
|
|
53
|
+
this.queue.push(send);
|
|
54
|
+
void this.drain().catch((error) => {
|
|
55
|
+
// A turn-level failure is contained inside runTurn; reaching here only
|
|
56
|
+
// means the runner itself broke — settle the conversation visibly.
|
|
57
|
+
this.emitStatus({ running: false, turn: -1, reason: 'error' });
|
|
58
|
+
this.emitMessage({
|
|
59
|
+
seq: -1,
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
blocks: [],
|
|
62
|
+
error: `chat: turn runner failed: ${error.message}`,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Abort the in-flight turn; returns whether one was running. */
|
|
68
|
+
cancel() {
|
|
69
|
+
if (!this.running || this.controller === null)
|
|
70
|
+
return false;
|
|
71
|
+
this.controller.abort();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
/** Stop queue advances; an in-flight turn still settles before the runner is dropped. */
|
|
75
|
+
dispose() {
|
|
76
|
+
this.disposed = true;
|
|
77
|
+
}
|
|
78
|
+
async drain() {
|
|
79
|
+
if (this.running || this.disposed)
|
|
80
|
+
return;
|
|
81
|
+
this.running = true;
|
|
82
|
+
try {
|
|
83
|
+
while (this.queue.length > 0 && !this.disposed) {
|
|
84
|
+
const send = this.queue.shift();
|
|
85
|
+
try {
|
|
86
|
+
await this.deps.onTurnStart?.();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// The conversation vanished mid-queue (deleted while drained): stop.
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await this.runTurn(send);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await this.deps.onTurnEnd?.().catch(() => { });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.running = false;
|
|
102
|
+
this.controller = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async runTurn(send) {
|
|
106
|
+
const record = await this.deps.record();
|
|
107
|
+
const all = await this.deps.store.readMessages(this.conversationId);
|
|
108
|
+
// Context boundary: this turn answers only its own user message. Later
|
|
109
|
+
// (queued) user messages stay out of the prompt until their own turn.
|
|
110
|
+
const history = typeof send.seq === 'number'
|
|
111
|
+
? all.filter(message => message.seq <= send.seq)
|
|
112
|
+
: all;
|
|
113
|
+
const window = await this.resolveWindow(record);
|
|
114
|
+
const assembled = assembleContext(history, record.contextMessages, window);
|
|
115
|
+
const seq = nextSeq(history);
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
this.controller = controller;
|
|
118
|
+
this.emitStatus({ running: true, turn: seq });
|
|
119
|
+
const parts = new Map();
|
|
120
|
+
const emitDelta = (index, text) => {
|
|
121
|
+
if (text.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
parts.set(index, (parts.get(index) ?? '') + text);
|
|
124
|
+
this.deps.sink.emitChunk(this.conversationId, seq, text);
|
|
125
|
+
};
|
|
126
|
+
let finishReason = 'completed';
|
|
127
|
+
let failure;
|
|
128
|
+
try {
|
|
129
|
+
const stream = this.deps.llm.stream({
|
|
130
|
+
provider: record.provider,
|
|
131
|
+
model: record.model,
|
|
132
|
+
messages: assembled.messages,
|
|
133
|
+
...record.systemPrompt === null ? {} : { system: record.systemPrompt },
|
|
134
|
+
...record.temperature === null ? {} : { temperature: record.temperature },
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
});
|
|
137
|
+
for await (const chunk of stream) {
|
|
138
|
+
if (this.disposed)
|
|
139
|
+
break;
|
|
140
|
+
if (chunk.type === 'text-delta' && typeof chunk.index === 'number' && typeof chunk.text === 'string') {
|
|
141
|
+
emitDelta(chunk.index, chunk.text);
|
|
142
|
+
}
|
|
143
|
+
else if (chunk.type === 'block-end' && chunk.index !== undefined && chunk.block?.type === 'text') {
|
|
144
|
+
// Some adapters deliver only the assembled block; backfill when no
|
|
145
|
+
// delta streamed for this index.
|
|
146
|
+
if (!parts.has(chunk.index) && typeof chunk.block.text === 'string' && chunk.block.text.length > 0) {
|
|
147
|
+
emitDelta(chunk.index, chunk.block.text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (chunk.type === 'finish') {
|
|
151
|
+
if (chunk.reason?.kind === 'aborted')
|
|
152
|
+
finishReason = 'aborted';
|
|
153
|
+
else if (chunk.reason?.kind === 'error') {
|
|
154
|
+
finishReason = 'error';
|
|
155
|
+
failure = chunk.reason.failure?.message ?? '模型调用失败';
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
finishReason = mapFinishKind(chunk.reason?.kind);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (isAbortError(error)) {
|
|
165
|
+
finishReason = 'aborted';
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
finishReason = 'error';
|
|
169
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const text = [...parts.entries()].sort((a, b) => a[0] - b[0]).map(entry => entry[1]).join('');
|
|
173
|
+
// The conversation was deleted while the turn streamed: settle without
|
|
174
|
+
// persisting (the log file is gone and must not be recreated).
|
|
175
|
+
if (this.disposed) {
|
|
176
|
+
this.emitStatus({ running: false, turn: seq, reason: 'aborted' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const blocks = text.length > 0
|
|
180
|
+
? [{ type: 'text', text }]
|
|
181
|
+
: [];
|
|
182
|
+
// An empty abort persists nothing (nothing was frozen to settle).
|
|
183
|
+
if (this.disposed || (finishReason === 'aborted' && text.length === 0)) {
|
|
184
|
+
this.emitStatus({ running: false, turn: seq, reason: 'aborted' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const message = {
|
|
188
|
+
seq,
|
|
189
|
+
role: 'assistant',
|
|
190
|
+
blocks,
|
|
191
|
+
model: record.model,
|
|
192
|
+
provider: record.provider,
|
|
193
|
+
...failure === undefined ? {} : { error: failure },
|
|
194
|
+
createdAt: Date.now(),
|
|
195
|
+
};
|
|
196
|
+
await this.deps.store.appendMessage(this.conversationId, message);
|
|
197
|
+
this.deps.sink.emitMessage(this.conversationId, message);
|
|
198
|
+
if (assembled.windowTrimmed && (finishReason === 'completed' || finishReason === 'max-tokens')) {
|
|
199
|
+
finishReason = 'context-trimmed';
|
|
200
|
+
}
|
|
201
|
+
this.emitStatus({ running: false, turn: seq, reason: finishReason });
|
|
202
|
+
}
|
|
203
|
+
emitMessage(message) {
|
|
204
|
+
if (this.disposed)
|
|
205
|
+
return;
|
|
206
|
+
this.deps.sink.emitMessage(this.conversationId, message);
|
|
207
|
+
}
|
|
208
|
+
emitStatus(status) {
|
|
209
|
+
if (this.disposed)
|
|
210
|
+
return;
|
|
211
|
+
this.deps.sink.emitStatus(this.conversationId, status);
|
|
212
|
+
}
|
|
213
|
+
async resolveWindow(record) {
|
|
214
|
+
try {
|
|
215
|
+
const info = await this.deps.llm.resolveModelInfo(record.provider, record.model);
|
|
216
|
+
return info.context?.contextWindow ?? null;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable storage for the chat domain: `$DSH_HOME/chat/index.json` plus one
|
|
3
|
+
* JSONL file per conversation (`$DSH_HOME/chat/<conversationId>.jsonl`).
|
|
4
|
+
* Kept free of official runtime imports so unit tests run in this repo; the
|
|
5
|
+
* live behavior is covered by the isolated-instance smoke matrix.
|
|
6
|
+
*
|
|
7
|
+
* Consistency model: every mutation serializes through one store-wide write
|
|
8
|
+
* lock (`withWriteLock`) and persists via a unique temp file + rename, so
|
|
9
|
+
* concurrent conversations cannot lose each other's index updates and a torn
|
|
10
|
+
* append cannot corrupt a log.
|
|
11
|
+
* @module @prur/dsh-chat-service/src/store
|
|
12
|
+
*/
|
|
13
|
+
import type { ChatMessage, ChatTextBlock } from './types.ts';
|
|
14
|
+
/** One still-queued user send (content persisted before the turn runs). */
|
|
15
|
+
export interface PendingSend {
|
|
16
|
+
/** Persisted user message seq this send belongs to — the turn's context boundary. */
|
|
17
|
+
readonly seq: number;
|
|
18
|
+
readonly content: readonly ChatTextBlock[];
|
|
19
|
+
readonly createdAt: number;
|
|
20
|
+
}
|
|
21
|
+
/** Durable per-conversation record, held in `index.json`. */
|
|
22
|
+
export interface ChatIndexRecord {
|
|
23
|
+
readonly conversationId: string;
|
|
24
|
+
readonly title: string | null;
|
|
25
|
+
readonly createdAt: number;
|
|
26
|
+
readonly updatedAt: number;
|
|
27
|
+
readonly provider: string;
|
|
28
|
+
readonly model: string;
|
|
29
|
+
readonly temperature: number | null;
|
|
30
|
+
readonly contextMessages: number | null;
|
|
31
|
+
readonly systemPrompt: string | null;
|
|
32
|
+
readonly queue: readonly PendingSend[];
|
|
33
|
+
/** Mirror of the live run state, persisted so restart recovery can resume the queue. */
|
|
34
|
+
readonly running: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Storage failure raised by a missing conversation; mapped to an RPC error. */
|
|
37
|
+
export declare class ChatStorageError extends Error {
|
|
38
|
+
readonly code: 'conversation-not-found' | 'io' | 'corrupt';
|
|
39
|
+
constructor(code: 'conversation-not-found' | 'io' | 'corrupt', message: string);
|
|
40
|
+
}
|
|
41
|
+
/** Hard cap on persisted messages per conversation (design §4.1). */
|
|
42
|
+
export declare const CHAT_MESSAGE_CAP = 500;
|
|
43
|
+
/** Marker row inserted after trimming: keeps the timeline readable for clients. */
|
|
44
|
+
export declare const CHAT_TRIM_MARKER_TEXT = "\u4E0A\u4E0B\u6587\u5DF2\u88C1\u526A";
|
|
45
|
+
/** Whether a candidate conversation id is safe to use as a filename stem. */
|
|
46
|
+
export declare function isSafeConversationId(conversationId: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* JSONL-backed chat store rooted at `chatHome/chat`. All mutations go through
|
|
49
|
+
* {@link withWriteLock}; readers stay lock-free (writes are atomic renames).
|
|
50
|
+
*/
|
|
51
|
+
export declare class ChatStore {
|
|
52
|
+
private readonly chatHome;
|
|
53
|
+
private writeTail;
|
|
54
|
+
constructor(chatHome: string);
|
|
55
|
+
private indexPath;
|
|
56
|
+
private logPath;
|
|
57
|
+
/** Serialize every mutation: one store-wide chain, errors contained per op. */
|
|
58
|
+
private withWriteLock;
|
|
59
|
+
ensure(): Promise<void>;
|
|
60
|
+
list(): Promise<ChatIndexRecord[]>;
|
|
61
|
+
get(conversationId: string): Promise<ChatIndexRecord | undefined>;
|
|
62
|
+
/** @throws {@link ChatStorageError} when the conversation does not exist. */
|
|
63
|
+
require(conversationId: string): Promise<ChatIndexRecord>;
|
|
64
|
+
put(record: ChatIndexRecord): Promise<void>;
|
|
65
|
+
/** Delete a conversation and its log; idempotent. */
|
|
66
|
+
remove(conversationId: string): Promise<void>;
|
|
67
|
+
readMessages(conversationId: string): Promise<ChatMessage[]>;
|
|
68
|
+
/**
|
|
69
|
+
* Append one message under the write lock and maintain the per-conversation
|
|
70
|
+
* cap: when the cap would be exceeded, the oldest messages are dropped and
|
|
71
|
+
* a system marker row is appended in their place (client renders a notice).
|
|
72
|
+
* @returns whether trimming occurred.
|
|
73
|
+
*/
|
|
74
|
+
appendMessage(conversationId: string, message: ChatMessage): Promise<{
|
|
75
|
+
trimmed: boolean;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Page history newest-first paging semantics like `session.history`: the
|
|
79
|
+
* tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
|
|
80
|
+
* ascending seq; `beforeSeq` returns the `maxMessages` messages strictly
|
|
81
|
+
* older than it, still ascending, with `hasMore` telling whether an older
|
|
82
|
+
* page remains.
|
|
83
|
+
*/
|
|
84
|
+
paginate(conversationId: string, beforeSeq: number | undefined, maxMessages: number): Promise<{
|
|
85
|
+
items: ChatMessage[];
|
|
86
|
+
hasMore: boolean;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable storage for the chat domain: `$DSH_HOME/chat/index.json` plus one
|
|
3
|
+
* JSONL file per conversation (`$DSH_HOME/chat/<conversationId>.jsonl`).
|
|
4
|
+
* Kept free of official runtime imports so unit tests run in this repo; the
|
|
5
|
+
* live behavior is covered by the isolated-instance smoke matrix.
|
|
6
|
+
*
|
|
7
|
+
* Consistency model: every mutation serializes through one store-wide write
|
|
8
|
+
* lock (`withWriteLock`) and persists via a unique temp file + rename, so
|
|
9
|
+
* concurrent conversations cannot lose each other's index updates and a torn
|
|
10
|
+
* append cannot corrupt a log.
|
|
11
|
+
* @module @prur/dsh-chat-service/src/store
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** Storage failure raised by a missing conversation; mapped to an RPC error. */
|
|
17
|
+
export class ChatStorageError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = 'ChatStorageError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Hard cap on persisted messages per conversation (design §4.1). */
|
|
26
|
+
export const CHAT_MESSAGE_CAP = 500;
|
|
27
|
+
/** Marker row inserted after trimming: keeps the timeline readable for clients. */
|
|
28
|
+
export const CHAT_TRIM_MARKER_TEXT = '上下文已裁剪';
|
|
29
|
+
/** conversationId charset guard: ids are opaque tokens, never path syntax. */
|
|
30
|
+
const CONVERSATION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
31
|
+
/** Whether a candidate conversation id is safe to use as a filename stem. */
|
|
32
|
+
export function isSafeConversationId(conversationId) {
|
|
33
|
+
return CONVERSATION_ID_PATTERN.test(conversationId);
|
|
34
|
+
}
|
|
35
|
+
async function readJson(path, fallback) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT')
|
|
41
|
+
return fallback;
|
|
42
|
+
throw new ChatStorageError('corrupt', `chat store: cannot read ${path}: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function writeFileAtomic(target, body) {
|
|
46
|
+
// Unique per write: concurrent mutations never share a temp path.
|
|
47
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
48
|
+
await writeFile(tmp, body, 'utf8');
|
|
49
|
+
await rename(tmp, target);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* JSONL-backed chat store rooted at `chatHome/chat`. All mutations go through
|
|
53
|
+
* {@link withWriteLock}; readers stay lock-free (writes are atomic renames).
|
|
54
|
+
*/
|
|
55
|
+
export class ChatStore {
|
|
56
|
+
chatHome;
|
|
57
|
+
writeTail = Promise.resolve();
|
|
58
|
+
constructor(chatHome) {
|
|
59
|
+
this.chatHome = chatHome;
|
|
60
|
+
}
|
|
61
|
+
indexPath() {
|
|
62
|
+
return join(this.chatHome, 'index.json');
|
|
63
|
+
}
|
|
64
|
+
logPath(conversationId) {
|
|
65
|
+
return join(this.chatHome, `${conversationId}.jsonl`);
|
|
66
|
+
}
|
|
67
|
+
/** Serialize every mutation: one store-wide chain, errors contained per op. */
|
|
68
|
+
withWriteLock(operation) {
|
|
69
|
+
const next = this.writeTail.then(operation, operation);
|
|
70
|
+
this.writeTail = next.catch(() => { });
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
async ensure() {
|
|
74
|
+
await mkdir(this.chatHome, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
async list() {
|
|
77
|
+
const records = await readJson(this.indexPath(), []);
|
|
78
|
+
return [...records].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
79
|
+
}
|
|
80
|
+
async get(conversationId) {
|
|
81
|
+
const records = await readJson(this.indexPath(), []);
|
|
82
|
+
return records.find(record => record.conversationId === conversationId);
|
|
83
|
+
}
|
|
84
|
+
/** @throws {@link ChatStorageError} when the conversation does not exist. */
|
|
85
|
+
async require(conversationId) {
|
|
86
|
+
const record = await this.get(conversationId);
|
|
87
|
+
if (record === undefined) {
|
|
88
|
+
throw new ChatStorageError('conversation-not-found', `chat: conversation "${conversationId}" does not exist`);
|
|
89
|
+
}
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
async put(record) {
|
|
93
|
+
return this.withWriteLock(async () => {
|
|
94
|
+
await this.ensure();
|
|
95
|
+
const records = await readJson(this.indexPath(), []);
|
|
96
|
+
const next = records.filter(candidate => candidate.conversationId !== record.conversationId);
|
|
97
|
+
next.push(record);
|
|
98
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(next, null, 2));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Delete a conversation and its log; idempotent. */
|
|
102
|
+
async remove(conversationId) {
|
|
103
|
+
return this.withWriteLock(async () => {
|
|
104
|
+
const records = await readJson(this.indexPath(), []);
|
|
105
|
+
await writeFileAtomic(this.indexPath(), JSON.stringify(records.filter(candidate => candidate.conversationId !== conversationId), null, 2));
|
|
106
|
+
await unlink(this.logPath(conversationId)).catch((error) => {
|
|
107
|
+
if (error.code !== 'ENOENT')
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async readMessages(conversationId) {
|
|
113
|
+
try {
|
|
114
|
+
const lines = (await readFile(this.logPath(conversationId), 'utf8')).split('\n');
|
|
115
|
+
const messages = [];
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (line.length === 0)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
messages.push(JSON.parse(line));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// A torn tail line (crash mid-append) is dropped, not fatal.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return messages;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code === 'ENOENT')
|
|
130
|
+
return [];
|
|
131
|
+
throw new ChatStorageError('io', `chat: cannot read log: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Append one message under the write lock and maintain the per-conversation
|
|
136
|
+
* cap: when the cap would be exceeded, the oldest messages are dropped and
|
|
137
|
+
* a system marker row is appended in their place (client renders a notice).
|
|
138
|
+
* @returns whether trimming occurred.
|
|
139
|
+
*/
|
|
140
|
+
async appendMessage(conversationId, message) {
|
|
141
|
+
return this.withWriteLock(async () => {
|
|
142
|
+
await this.ensure();
|
|
143
|
+
const messages = await this.readMessages(conversationId);
|
|
144
|
+
messages.push(message);
|
|
145
|
+
const excess = messages.length - CHAT_MESSAGE_CAP;
|
|
146
|
+
let trimmed = false;
|
|
147
|
+
let next = messages;
|
|
148
|
+
if (excess > 0) {
|
|
149
|
+
trimmed = true;
|
|
150
|
+
// One marker at a time: drop every existing system row, then trim the
|
|
151
|
+
// oldest real rows to the cap minus the marker slot, then append the
|
|
152
|
+
// fresh marker (a notice at the trim boundary, not a per-append flood).
|
|
153
|
+
const real = messages.filter(message => message.role !== 'system');
|
|
154
|
+
const kept = real.slice(real.length - (CHAT_MESSAGE_CAP - 1));
|
|
155
|
+
const marker = {
|
|
156
|
+
seq: message.seq + excess,
|
|
157
|
+
role: 'system',
|
|
158
|
+
blocks: [{ type: 'text', text: CHAT_TRIM_MARKER_TEXT }],
|
|
159
|
+
createdAt: Date.now(),
|
|
160
|
+
};
|
|
161
|
+
next = [...kept, marker];
|
|
162
|
+
}
|
|
163
|
+
await writeFileAtomic(this.logPath(conversationId), `${next.map(item => JSON.stringify(item)).join('\n')}\n`);
|
|
164
|
+
return { trimmed };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Page history newest-first paging semantics like `session.history`: the
|
|
169
|
+
* tail page (no `beforeSeq`) returns the newest `maxMessages` messages in
|
|
170
|
+
* ascending seq; `beforeSeq` returns the `maxMessages` messages strictly
|
|
171
|
+
* older than it, still ascending, with `hasMore` telling whether an older
|
|
172
|
+
* page remains.
|
|
173
|
+
*/
|
|
174
|
+
async paginate(conversationId, beforeSeq, maxMessages) {
|
|
175
|
+
const messages = await this.readMessages(conversationId);
|
|
176
|
+
const eligible = beforeSeq === undefined
|
|
177
|
+
? messages
|
|
178
|
+
: messages.filter(message => message.seq < beforeSeq);
|
|
179
|
+
const tail = eligible.slice(-maxMessages);
|
|
180
|
+
return {
|
|
181
|
+
items: tail,
|
|
182
|
+
hasMore: eligible.length > tail.length,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-written TYPERT host manifest for the chat Remote namespace. The
|
|
3
|
+
* official typert generator emits this artifact from the service face during
|
|
4
|
+
* the harness repository build; this package hand-writes the same contract
|
|
5
|
+
* (wire schemas + invocation descriptors) because the bridge repo builds
|
|
6
|
+
* packages with plain tsc. The typert-loader validates every field at mount
|
|
7
|
+
* time; the isolated-instance matrix asserts acceptance on boot.
|
|
8
|
+
* @module @prur/dsh-chat-service/typert
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
/** One parameter wire descriptor (strict codec). */
|
|
12
|
+
interface InvocationParam {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly wire: string;
|
|
15
|
+
readonly source: 'json';
|
|
16
|
+
readonly acceptsUndefined?: true;
|
|
17
|
+
readonly codec: {
|
|
18
|
+
readonly mode: 'strict';
|
|
19
|
+
readonly typeSymbol: string;
|
|
20
|
+
readonly schema: z.ZodType;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** One invocation descriptor as validated by the typert-loader at mount. */
|
|
24
|
+
interface InvocationEntry {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly service: string;
|
|
27
|
+
readonly namespace: string;
|
|
28
|
+
readonly method: string;
|
|
29
|
+
readonly invocation: {
|
|
30
|
+
readonly kind: 'direct';
|
|
31
|
+
};
|
|
32
|
+
readonly parameters: readonly InvocationParam[];
|
|
33
|
+
readonly result: {
|
|
34
|
+
readonly mode: 'strict';
|
|
35
|
+
readonly typeSymbol: string;
|
|
36
|
+
readonly schema: z.ZodType;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export declare const TYPERT: {
|
|
40
|
+
package: string;
|
|
41
|
+
face: string;
|
|
42
|
+
schemas: never[];
|
|
43
|
+
invocations: InvocationEntry[];
|
|
44
|
+
model: {
|
|
45
|
+
services: never[];
|
|
46
|
+
events: never[];
|
|
47
|
+
objects: never[];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export default TYPERT;
|