@deepseek-ai/dsh-session-title 0.0.1-rc.1
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 +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +55 -0
- package/README.zh.md +55 -0
- package/lib/index.js +580 -0
- package/lib/invariant.js +38 -0
- package/lib/types/client.d.ts +10 -0
- package/lib/types/client.js +10 -0
- package/lib/types/index.d.ts +230 -0
- package/lib/types/index.js +601 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +41 -0
- package/lib/types/normalize.d.ts +24 -0
- package/lib/types/normalize.js +71 -0
- package/lib/types/types.d.ts +21 -0
- package/lib/types/types.js +11 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { z as z$1 } from "zod";
|
|
4
|
+
import { assertNever, deepFreeze, isAgentLoopRequest } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
//#region lib/types/normalize.js
|
|
6
|
+
/** Title text normalization and UTF-8-safe truncation. */
|
|
7
|
+
/** Operating-system-command escape sequences, including unterminated tails. */
|
|
8
|
+
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu;
|
|
9
|
+
/** Control-sequence-introducer escapes such as SGR color codes. */
|
|
10
|
+
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
|
|
11
|
+
/** Remaining two-byte ESC control sequences. */
|
|
12
|
+
const ESC_SEQUENCE = /\u001B[@-_]/gu;
|
|
13
|
+
/** Non-whitespace C0/C1 control characters. */
|
|
14
|
+
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu;
|
|
15
|
+
/** Directional and invisible controls that can make a displayed title deceptive. */
|
|
16
|
+
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu;
|
|
17
|
+
/** Reject an invalid public text limit. */
|
|
18
|
+
function assertPositiveInteger$1(name, value) {
|
|
19
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
20
|
+
}
|
|
21
|
+
/** Remove controls and produce one trimmed, whitespace-normalized line. */
|
|
22
|
+
function cleanTitleText(input) {
|
|
23
|
+
return input.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESC_SEQUENCE, "").replace(CONTROL_CHARACTER, "").replace(DIRECTIONAL_CONTROL, "").replace(/\s+/gu, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
|
|
27
|
+
* @param input - normalized title text.
|
|
28
|
+
* @param maxBytes - positive UTF-8 byte budget.
|
|
29
|
+
* @returns the longest leading code-point prefix within the budget.
|
|
30
|
+
*/
|
|
31
|
+
function truncateTitleUtf8(input, maxBytes) {
|
|
32
|
+
assertPositiveInteger$1("maxBytes", maxBytes);
|
|
33
|
+
if (Buffer.byteLength(input, "utf8") <= maxBytes) return input;
|
|
34
|
+
let used = 0;
|
|
35
|
+
let output = "";
|
|
36
|
+
for (const character of input) {
|
|
37
|
+
const bytes = Buffer.byteLength(character, "utf8");
|
|
38
|
+
if (used + bytes > maxBytes) break;
|
|
39
|
+
output += character;
|
|
40
|
+
used += bytes;
|
|
41
|
+
}
|
|
42
|
+
return output;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Normalize one accepted session title and enforce its UTF-8 byte budget.
|
|
46
|
+
* @param input - untrusted title text.
|
|
47
|
+
* @param maxBytes - positive maximum encoded size.
|
|
48
|
+
* @returns a terminal-safe one-line title, possibly empty after sanitization.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeSessionTitle(input, maxBytes) {
|
|
51
|
+
return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Derive the deterministic first-message fallback.
|
|
55
|
+
* @param input - text from the first eligible human message.
|
|
56
|
+
* @param maxWords - positive whitespace-delimited word cap.
|
|
57
|
+
* @param maxBytes - positive UTF-8 byte cap.
|
|
58
|
+
* @returns the normalized leading words within both limits.
|
|
59
|
+
*/
|
|
60
|
+
function fallbackSessionTitle(input, maxWords, maxBytes) {
|
|
61
|
+
assertPositiveInteger$1("maxWords", maxWords);
|
|
62
|
+
return truncateTitleUtf8(cleanTitleText(input).split(" ").filter(Boolean).slice(0, maxWords).join(" "), maxBytes).trimEnd();
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region lib/types/index.js
|
|
66
|
+
/**
|
|
67
|
+
* Log-backed session title service, deterministic fallback, and provider contract.
|
|
68
|
+
* @module @deepseek-ai/dsh-session-title
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Brand a raw provider id.
|
|
72
|
+
* @param id - stable non-empty provider identifier supplied by a plugin.
|
|
73
|
+
* @returns the same string with the session-title provider brand.
|
|
74
|
+
*/
|
|
75
|
+
function SessionTitleProviderId(id) {
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Rejection of an explicit user title whose text normalizes to empty — the
|
|
80
|
+
* one {@link SessionTitleService.rename} failure that blames the input.
|
|
81
|
+
* Callers translating rename failures onto a wire (`title-invalid`) narrow on
|
|
82
|
+
* this class; liveness and disposal failures stay plain `Error`s.
|
|
83
|
+
*/
|
|
84
|
+
var SessionTitleInvalidError = class extends Error {
|
|
85
|
+
name = "SessionTitleInvalidError";
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Collect human text-bearing user messages in log order.
|
|
89
|
+
* @param events - session log or persisted replay.
|
|
90
|
+
* @param throughSeq - optional inclusive event boundary.
|
|
91
|
+
* @returns eligible messages with exact source seqs.
|
|
92
|
+
*/
|
|
93
|
+
function collectSessionTitleMessages(events, throughSeq) {
|
|
94
|
+
const messages = [];
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
if (throughSeq !== void 0 && event.seq > throughSeq) break;
|
|
97
|
+
if (event.type !== "user/message" || event.data.source.kind !== "user") continue;
|
|
98
|
+
const text = event.data.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
99
|
+
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue;
|
|
100
|
+
messages.push({
|
|
101
|
+
seq: event.seq,
|
|
102
|
+
text
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return messages;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fold the latest logged title without consulting mutable metadata.
|
|
109
|
+
* @param events - live or persisted session log.
|
|
110
|
+
* @returns the latest immutable title snapshot, or `undefined`.
|
|
111
|
+
*/
|
|
112
|
+
function foldSessionTitle(events) {
|
|
113
|
+
const event = events.findLast((item) => item.type === "session/title");
|
|
114
|
+
if (event === void 0) return void 0;
|
|
115
|
+
return deepFreeze({
|
|
116
|
+
title: event.data.title,
|
|
117
|
+
messageSeqs: [...event.data.messageSeqs],
|
|
118
|
+
source: copySessionTitleSource(event.data.source),
|
|
119
|
+
eventSeq: event.seq,
|
|
120
|
+
updatedAt: event.time
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
|
|
124
|
+
function copySessionTitleSource(source) {
|
|
125
|
+
switch (source.kind) {
|
|
126
|
+
case "fallback": return { kind: "fallback" };
|
|
127
|
+
case "provider": return {
|
|
128
|
+
kind: "provider",
|
|
129
|
+
provider: source.provider,
|
|
130
|
+
...source.model === void 0 ? {} : { model: { ...source.model } }
|
|
131
|
+
};
|
|
132
|
+
case "user": return { kind: "user" };
|
|
133
|
+
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
134
|
+
default: return assertNever(source, "SessionTitleSource");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Validate one positive integer configuration field. */
|
|
138
|
+
function assertPositiveInteger(name, value) {
|
|
139
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`session-title: ${name} must be a positive integer`);
|
|
140
|
+
}
|
|
141
|
+
/** Log-backed title fold plus asynchronous fallback generation. */
|
|
142
|
+
var SessionTitleService = class extends Service {
|
|
143
|
+
static inject = ["sessions"];
|
|
144
|
+
static Config = z.object({
|
|
145
|
+
fallbackMaxWords: z.number().step(1).min(1).required(),
|
|
146
|
+
fallbackMaxBytes: z.number().step(1).min(1).required(),
|
|
147
|
+
maxTitleBytes: z.number().step(1).min(1).required()
|
|
148
|
+
});
|
|
149
|
+
config;
|
|
150
|
+
ownerFiber;
|
|
151
|
+
registration;
|
|
152
|
+
work = /* @__PURE__ */ new Map();
|
|
153
|
+
lifetime = new AbortController();
|
|
154
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
155
|
+
constructor(ctx, config) {
|
|
156
|
+
super(ctx, "sessionTitle");
|
|
157
|
+
this.ownerFiber = ctx.fiber;
|
|
158
|
+
const candidate = config;
|
|
159
|
+
if (candidate === null || typeof candidate !== "object") throw new Error("session-title: configuration is required");
|
|
160
|
+
const value = candidate;
|
|
161
|
+
assertPositiveInteger("fallbackMaxWords", value.fallbackMaxWords);
|
|
162
|
+
assertPositiveInteger("fallbackMaxBytes", value.fallbackMaxBytes);
|
|
163
|
+
assertPositiveInteger("maxTitleBytes", value.maxTitleBytes);
|
|
164
|
+
if (value.fallbackMaxBytes > value.maxTitleBytes) throw new Error("session-title: fallbackMaxBytes must not exceed maxTitleBytes");
|
|
165
|
+
this.config = deepFreeze({ ...value });
|
|
166
|
+
ctx.effect(() => async () => {
|
|
167
|
+
this.lifetime.abort(/* @__PURE__ */ new Error("session-title service disposed"));
|
|
168
|
+
if (this.registration !== void 0) this.registration.closing = true;
|
|
169
|
+
this.registration = void 0;
|
|
170
|
+
for (const state of this.work.values()) {
|
|
171
|
+
delete state.pending;
|
|
172
|
+
state.active?.controller.abort(/* @__PURE__ */ new Error("session-title service disposed"));
|
|
173
|
+
}
|
|
174
|
+
await this.drain(this.inFlight);
|
|
175
|
+
this.work.clear();
|
|
176
|
+
}, "sessionTitle lifecycle");
|
|
177
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
178
|
+
projectionCtx.sessionProjections.register({
|
|
179
|
+
key: "title",
|
|
180
|
+
schema: z$1.union([z$1.string().min(1), z$1.null()]),
|
|
181
|
+
init: () => null,
|
|
182
|
+
apply: (state, event) => event.type === "session/title" ? event.data.title : state,
|
|
183
|
+
view: (state) => state,
|
|
184
|
+
stateVersion: 1
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
ctx.on("session/event", (session, event) => {
|
|
188
|
+
switch (event.type) {
|
|
189
|
+
case "user/message":
|
|
190
|
+
this.onUserMessage(session, event);
|
|
191
|
+
break;
|
|
192
|
+
case "request/header":
|
|
193
|
+
this.onRequestHeader(session, event);
|
|
194
|
+
break;
|
|
195
|
+
default: break;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
ctx.on("llm/stream", (options, next) => {
|
|
199
|
+
this.onMainRequest(options);
|
|
200
|
+
return next();
|
|
201
|
+
}, {
|
|
202
|
+
global: true,
|
|
203
|
+
prepend: true
|
|
204
|
+
});
|
|
205
|
+
ctx.on("session/disposed", (session) => {
|
|
206
|
+
const state = this.work.get(session);
|
|
207
|
+
if (state === void 0) return;
|
|
208
|
+
state.active?.controller.abort(/* @__PURE__ */ new Error("session disposed during title generation"));
|
|
209
|
+
this.work.delete(session);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Read the latest folded title from one live or replayed session.
|
|
214
|
+
* @param session - session whose log is the title source of truth.
|
|
215
|
+
* @returns latest title snapshot, or `undefined` before eligible input.
|
|
216
|
+
*/
|
|
217
|
+
get(session) {
|
|
218
|
+
return foldSessionTitle(session.events);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Accept an explicit user title. Appends a `session/title` event with the
|
|
222
|
+
* `user` source, which pins the title: in-flight automatic generation is
|
|
223
|
+
* superseded and later user messages schedule none (an explicit
|
|
224
|
+
* {@link SessionTitleService.refresh} remains the deliberate unpin).
|
|
225
|
+
* @param session - exact live session to rename.
|
|
226
|
+
* @param title - raw user input; normalized before acceptance.
|
|
227
|
+
* @returns the accepted title snapshot.
|
|
228
|
+
* @throws {SessionTitleInvalidError} when the title normalizes to empty.
|
|
229
|
+
* @throws {Error} when the session is not live or the service is disposed.
|
|
230
|
+
*/
|
|
231
|
+
rename(session, title) {
|
|
232
|
+
this.assertServiceActive();
|
|
233
|
+
if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
|
|
234
|
+
const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes);
|
|
235
|
+
if (normalized.length === 0) throw new SessionTitleInvalidError("session title must contain visible characters");
|
|
236
|
+
const state = this.stateFor(session);
|
|
237
|
+
this.supersede(state, "user rename superseded automatic title generation");
|
|
238
|
+
session.append("session/title", {
|
|
239
|
+
title: normalized,
|
|
240
|
+
messageSeqs: [],
|
|
241
|
+
source: { kind: "user" }
|
|
242
|
+
});
|
|
243
|
+
const snapshot = this.get(session);
|
|
244
|
+
/* v8 ignore next -- unreachable: the append above just committed a session/title event. */
|
|
245
|
+
if (snapshot === void 0) throw new Error("renamed title failed to fold");
|
|
246
|
+
return snapshot;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Explicitly retry the registered provider, or materialize the built-in
|
|
250
|
+
* fallback when no provider is registered.
|
|
251
|
+
* @param session - exact live session to refresh.
|
|
252
|
+
* @param signal - optional caller cancellation.
|
|
253
|
+
* @returns latest accepted title, or `undefined` when no eligible text exists.
|
|
254
|
+
*/
|
|
255
|
+
async refresh(session, signal) {
|
|
256
|
+
signal?.throwIfAborted();
|
|
257
|
+
this.assertServiceActive();
|
|
258
|
+
if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
|
|
259
|
+
const registration = this.registration;
|
|
260
|
+
const messages = collectSessionTitleMessages(session.events);
|
|
261
|
+
const latest = messages.at(-1);
|
|
262
|
+
if (registration === void 0 || registration.closing || latest === void 0) {
|
|
263
|
+
const current = this.get(session);
|
|
264
|
+
const [first] = messages;
|
|
265
|
+
if (current?.source.kind === "user" && first !== void 0) {
|
|
266
|
+
this.appendFallback(session, first);
|
|
267
|
+
signal?.throwIfAborted();
|
|
268
|
+
return this.get(session);
|
|
269
|
+
}
|
|
270
|
+
const fallback = await this.ensureFallback(session);
|
|
271
|
+
signal?.throwIfAborted();
|
|
272
|
+
return fallback;
|
|
273
|
+
}
|
|
274
|
+
const state = this.stateFor(session);
|
|
275
|
+
const revision = this.supersede(state, "explicit title refresh superseded older generation");
|
|
276
|
+
const work = this.activate({
|
|
277
|
+
registration,
|
|
278
|
+
revision,
|
|
279
|
+
throughSeq: latest.seq
|
|
280
|
+
}, state, signal);
|
|
281
|
+
const config = session.requestHeader()?.config;
|
|
282
|
+
const route = config === void 0 ? void 0 : {
|
|
283
|
+
provider: config.provider,
|
|
284
|
+
model: config.model
|
|
285
|
+
};
|
|
286
|
+
return this.startProvider(session, work, route);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Register the sole optional title provider. Disposal aborts its pending and
|
|
290
|
+
* active work before another provider may register.
|
|
291
|
+
* @param provider - provider identity, cadence, and generation function.
|
|
292
|
+
* @returns exact Cordis effect disposer, which settles after active calls quiesce.
|
|
293
|
+
*/
|
|
294
|
+
register(provider) {
|
|
295
|
+
this.validateProvider(provider);
|
|
296
|
+
if (this.registration !== void 0) throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`);
|
|
297
|
+
const registration = {
|
|
298
|
+
provider,
|
|
299
|
+
active: /* @__PURE__ */ new Set(),
|
|
300
|
+
closing: false
|
|
301
|
+
};
|
|
302
|
+
return this.ctx.effect(function* () {
|
|
303
|
+
this.registration = registration;
|
|
304
|
+
yield async () => {
|
|
305
|
+
registration.closing = true;
|
|
306
|
+
for (const state of this.work.values()) {
|
|
307
|
+
if (state.pending?.registration === registration) delete state.pending;
|
|
308
|
+
if (state.active?.registration === registration) state.active.controller.abort(/* @__PURE__ */ new Error(`session-title provider "${provider.id}" was disposed`));
|
|
309
|
+
}
|
|
310
|
+
await this.drain(registration.active);
|
|
311
|
+
if (this.registration === registration) this.registration = void 0;
|
|
312
|
+
};
|
|
313
|
+
}.bind(this), "sessionTitle.register()");
|
|
314
|
+
}
|
|
315
|
+
/** Schedule fallback creation and any provider cadence for one eligible event. */
|
|
316
|
+
onUserMessage(session, event) {
|
|
317
|
+
if (!this.serviceActive()) return;
|
|
318
|
+
if (event.data.source.kind !== "user" || collectSessionTitleMessages([event]).length === 0) return;
|
|
319
|
+
if (this.get(session)?.source.kind === "user") return;
|
|
320
|
+
const registration = this.registration;
|
|
321
|
+
if (registration !== void 0 && !registration.closing) {
|
|
322
|
+
const messages = collectSessionTitleMessages(session.events, event.seq);
|
|
323
|
+
if (registration.provider.automatic === "all-user-messages" || session.header.parentSession === void 0 && messages.length === 1 && this.get(session) === void 0) {
|
|
324
|
+
const state = this.stateFor(session);
|
|
325
|
+
state.pending = {
|
|
326
|
+
registration,
|
|
327
|
+
revision: this.supersede(state, "newer user message superseded title generation"),
|
|
328
|
+
throughSeq: event.seq
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
this.defer(async () => {
|
|
333
|
+
try {
|
|
334
|
+
await this.ensureFallback(session);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!this.serviceActive()) return;
|
|
337
|
+
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** Start pending automatic work only after its exact main-request route is logged. */
|
|
342
|
+
onRequestHeader(session, event) {
|
|
343
|
+
if (!this.serviceActive()) return;
|
|
344
|
+
const state = this.work.get(session);
|
|
345
|
+
const pending = state?.pending;
|
|
346
|
+
if (state === void 0 || pending === void 0 || pending.throughSeq >= event.seq) return;
|
|
347
|
+
const route = {
|
|
348
|
+
provider: event.data.header.config.provider,
|
|
349
|
+
model: event.data.header.config.model
|
|
350
|
+
};
|
|
351
|
+
this.startPending(session, state, pending, route);
|
|
352
|
+
}
|
|
353
|
+
/** Start unchanged-route work from the marked loop request after its header fold is current. */
|
|
354
|
+
onMainRequest(options) {
|
|
355
|
+
if (!this.serviceActive() || options.sessionId === void 0 || !isAgentLoopRequest(options)) return;
|
|
356
|
+
const session = this.ctx.sessions.get(options.sessionId);
|
|
357
|
+
const state = session === void 0 ? void 0 : this.work.get(session);
|
|
358
|
+
const pending = state?.pending;
|
|
359
|
+
if (session === void 0 || state === void 0 || pending === void 0) return;
|
|
360
|
+
const boundary = session.events.findLast((event) => event.type === "step/start" || event.type === "step/end");
|
|
361
|
+
const route = session.requestHeader()?.config;
|
|
362
|
+
if (boundary?.type !== "step/start" || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return;
|
|
363
|
+
this.startPending(session, state, pending, {
|
|
364
|
+
provider: options.provider,
|
|
365
|
+
model: options.model
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/** Consume one pending revision and schedule its non-blocking provider call. */
|
|
369
|
+
startPending(session, state, pending, route) {
|
|
370
|
+
delete state.pending;
|
|
371
|
+
this.defer(async () => {
|
|
372
|
+
if (this.registration !== pending.registration || pending.registration.closing || this.work.get(session) !== state || state.revision !== pending.revision) return;
|
|
373
|
+
const work = this.activate(pending, state);
|
|
374
|
+
try {
|
|
375
|
+
await this.startProvider(session, work, route);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (work.signal.aborted || !this.serviceActive()) return;
|
|
378
|
+
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** Start one tracked provider call after publishing its active revision. */
|
|
383
|
+
startProvider(session, work, route) {
|
|
384
|
+
const run = Promise.resolve().then(() => this.runProvider(session, work, route));
|
|
385
|
+
return this.track(run, work.registration);
|
|
386
|
+
}
|
|
387
|
+
/** Execute and accept one current provider revision. */
|
|
388
|
+
async runProvider(session, work, route) {
|
|
389
|
+
try {
|
|
390
|
+
this.assertCurrent(session, work);
|
|
391
|
+
await this.ensureFallback(session);
|
|
392
|
+
this.assertCurrent(session, work);
|
|
393
|
+
const messages = collectSessionTitleMessages(session.events, work.throughSeq);
|
|
394
|
+
const result = await work.registration.provider.generate({
|
|
395
|
+
session,
|
|
396
|
+
messages,
|
|
397
|
+
...route === void 0 ? {} : { route },
|
|
398
|
+
signal: work.signal
|
|
399
|
+
});
|
|
400
|
+
this.assertCurrent(session, work);
|
|
401
|
+
const accepted = this.validateResult(result, messages);
|
|
402
|
+
session.append("session/title", {
|
|
403
|
+
title: accepted.title,
|
|
404
|
+
messageSeqs: [...accepted.messageSeqs],
|
|
405
|
+
source: {
|
|
406
|
+
kind: "provider",
|
|
407
|
+
provider: work.registration.provider.id,
|
|
408
|
+
...accepted.model === void 0 ? {} : { model: accepted.model }
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
return this.get(session);
|
|
412
|
+
} finally {
|
|
413
|
+
const state = this.work.get(session);
|
|
414
|
+
if (state?.active === work) delete state.active;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Validate and normalize provider output against the supplied message snapshot. */
|
|
418
|
+
validateResult(result, messages) {
|
|
419
|
+
if (result === null || typeof result !== "object") throw new Error("session-title provider returned an invalid result");
|
|
420
|
+
const candidate = result;
|
|
421
|
+
if (typeof candidate.title !== "string") throw new Error("session-title provider title must be a string");
|
|
422
|
+
const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes);
|
|
423
|
+
if (title.length === 0) throw new Error("session-title provider returned an empty title");
|
|
424
|
+
if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) throw new Error("session-title provider must identify at least one source message seq");
|
|
425
|
+
const messageSeqs = [];
|
|
426
|
+
const order = new Map(messages.map((message, index) => [message.seq, index]));
|
|
427
|
+
let previous = -1;
|
|
428
|
+
for (const seq of candidate.messageSeqs) {
|
|
429
|
+
if (typeof seq !== "number") throw new Error("session-title provider messageSeqs must be unique, ordered seqs from the request");
|
|
430
|
+
const index = order.get(seq);
|
|
431
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || index === void 0 || index <= previous) throw new Error("session-title provider messageSeqs must be unique, ordered seqs from the request");
|
|
432
|
+
messageSeqs.push(seq);
|
|
433
|
+
previous = index;
|
|
434
|
+
}
|
|
435
|
+
const modelCandidate = candidate.model;
|
|
436
|
+
let model;
|
|
437
|
+
if (modelCandidate !== void 0) {
|
|
438
|
+
if (modelCandidate === null || typeof modelCandidate !== "object") throw new Error("session-title provider result model must contain non-empty provider and model strings");
|
|
439
|
+
const record = modelCandidate;
|
|
440
|
+
if (typeof record.provider !== "string" || record.provider.length === 0 || typeof record.model !== "string" || record.model.length === 0) throw new Error("session-title provider result model must contain non-empty provider and model strings");
|
|
441
|
+
model = {
|
|
442
|
+
provider: record.provider,
|
|
443
|
+
model: record.model
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
title,
|
|
448
|
+
messageSeqs,
|
|
449
|
+
...model === void 0 ? {} : { model }
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** Fail a completion whose provider, revision, session, or signal is stale. */
|
|
453
|
+
assertCurrent(session, work) {
|
|
454
|
+
this.assertServiceActive();
|
|
455
|
+
work.signal.throwIfAborted();
|
|
456
|
+
const state = this.work.get(session);
|
|
457
|
+
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
|
|
458
|
+
* the work signal before changing this state. */
|
|
459
|
+
if (this.registration !== work.registration || state?.active !== work || state.revision !== work.revision || this.ctx.sessions.get(session.id) !== session) throw new Error("session title generation state changed without cancellation");
|
|
460
|
+
}
|
|
461
|
+
/** Create and publish an active provider call from one fixed revision. */
|
|
462
|
+
activate(pending, state, upstream) {
|
|
463
|
+
const controller = new AbortController();
|
|
464
|
+
const signal = upstream === void 0 ? AbortSignal.any([controller.signal, this.lifetime.signal]) : AbortSignal.any([
|
|
465
|
+
controller.signal,
|
|
466
|
+
this.lifetime.signal,
|
|
467
|
+
upstream
|
|
468
|
+
]);
|
|
469
|
+
const work = {
|
|
470
|
+
...pending,
|
|
471
|
+
controller,
|
|
472
|
+
signal
|
|
473
|
+
};
|
|
474
|
+
state.active = work;
|
|
475
|
+
return work;
|
|
476
|
+
}
|
|
477
|
+
/** Abort older active work and reserve the next session-local revision. */
|
|
478
|
+
supersede(state, reason) {
|
|
479
|
+
state.active?.controller.abort(new Error(reason));
|
|
480
|
+
delete state.pending;
|
|
481
|
+
state.revision += 1;
|
|
482
|
+
return state.revision;
|
|
483
|
+
}
|
|
484
|
+
/** Return mutable work state for one session. */
|
|
485
|
+
stateFor(session) {
|
|
486
|
+
let state = this.work.get(session);
|
|
487
|
+
if (state === void 0) {
|
|
488
|
+
state = { revision: 0 };
|
|
489
|
+
this.work.set(session, state);
|
|
490
|
+
}
|
|
491
|
+
return state;
|
|
492
|
+
}
|
|
493
|
+
/** Queue detached service work and retain it through service disposal. */
|
|
494
|
+
defer(task) {
|
|
495
|
+
const run = Promise.resolve().then(async () => {
|
|
496
|
+
if (!this.serviceActive()) return;
|
|
497
|
+
await task();
|
|
498
|
+
});
|
|
499
|
+
this.track(run);
|
|
500
|
+
}
|
|
501
|
+
/** Retain one promise until settlement for service and optional provider teardown. */
|
|
502
|
+
track(run, registration) {
|
|
503
|
+
this.inFlight.add(run);
|
|
504
|
+
registration?.active.add(run);
|
|
505
|
+
const settled = () => {
|
|
506
|
+
this.inFlight.delete(run);
|
|
507
|
+
registration?.active.delete(run);
|
|
508
|
+
};
|
|
509
|
+
run.then(settled, settled);
|
|
510
|
+
return run;
|
|
511
|
+
}
|
|
512
|
+
/** Await every current and settling promise in one lifecycle registry. */
|
|
513
|
+
async drain(active) {
|
|
514
|
+
while (active.size > 0) await Promise.allSettled([...active]);
|
|
515
|
+
}
|
|
516
|
+
/** Whether the owning plugin fiber can still start or commit title work. */
|
|
517
|
+
serviceActive() {
|
|
518
|
+
return !this.lifetime.signal.aborted && this.ownerFiber.uid !== null && this.ownerFiber.state === 2;
|
|
519
|
+
}
|
|
520
|
+
/** Reject work once the owning plugin fiber has begun unloading. */
|
|
521
|
+
assertServiceActive() {
|
|
522
|
+
if (!this.serviceActive()) throw new Error("session-title service disposed");
|
|
523
|
+
}
|
|
524
|
+
/** Reject malformed provider registrations before publishing an effect. */
|
|
525
|
+
validateProvider(provider) {
|
|
526
|
+
if (provider === null || typeof provider !== "object") throw new Error("session-title provider must be an object");
|
|
527
|
+
const candidate = provider;
|
|
528
|
+
if (typeof candidate.id !== "string" || candidate.id.length === 0) throw new Error("session-title provider id must be a non-empty string");
|
|
529
|
+
if (candidate.automatic !== "first-message" && candidate.automatic !== "all-user-messages") throw new Error("session-title provider automatic mode is invalid");
|
|
530
|
+
if (typeof candidate.generate !== "function") throw new Error(`session-title provider "${candidate.id}" requires generate()`);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Derive and append the deterministic fallback title over whatever stands
|
|
534
|
+
* (the refresh unpin path: overwriting a pinned user title is the point).
|
|
535
|
+
* Synchronous on purpose — no await may separate derivation from append, so
|
|
536
|
+
* it needs neither ensureFallback's in-flight dedup nor its liveness
|
|
537
|
+
* re-check. An underivable fallback (empty after the caps) appends nothing.
|
|
538
|
+
*/
|
|
539
|
+
appendFallback(session, first) {
|
|
540
|
+
const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
|
|
541
|
+
if (title.length === 0) return;
|
|
542
|
+
session.append("session/title", {
|
|
543
|
+
title,
|
|
544
|
+
messageSeqs: [first.seq],
|
|
545
|
+
source: { kind: "fallback" }
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
/** Create the first deterministic fallback if the session still lacks a title. */
|
|
549
|
+
async ensureFallback(session) {
|
|
550
|
+
this.assertServiceActive();
|
|
551
|
+
const current = this.get(session);
|
|
552
|
+
if (current !== void 0) return current;
|
|
553
|
+
const [first] = collectSessionTitleMessages(session.events);
|
|
554
|
+
if (first === void 0) return void 0;
|
|
555
|
+
const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
|
|
556
|
+
if (title.length === 0) return void 0;
|
|
557
|
+
const state = this.stateFor(session);
|
|
558
|
+
if (state.fallback !== void 0) return state.fallback;
|
|
559
|
+
const fallback = Promise.resolve().then(() => {
|
|
560
|
+
this.assertServiceActive();
|
|
561
|
+
if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
|
|
562
|
+
const accepted = this.get(session);
|
|
563
|
+
if (accepted !== void 0) return accepted;
|
|
564
|
+
session.append("session/title", {
|
|
565
|
+
title,
|
|
566
|
+
messageSeqs: [first.seq],
|
|
567
|
+
source: { kind: "fallback" }
|
|
568
|
+
});
|
|
569
|
+
return this.get(session);
|
|
570
|
+
});
|
|
571
|
+
state.fallback = fallback;
|
|
572
|
+
try {
|
|
573
|
+
return await fallback;
|
|
574
|
+
} finally {
|
|
575
|
+
delete state.fallback;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
//#endregion
|
|
580
|
+
export { SessionTitleInvalidError, SessionTitleProviderId, SessionTitleService, SessionTitleService as default, collectSessionTitleMessages, fallbackSessionTitle, foldSessionTitle, normalizeSessionTitle, truncateTitleUtf8 };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title`.
|
|
4
|
+
* @module @deepseek-ai/dsh-session-title/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-session-title";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "session-title-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* Durable title-source invariant: an automatic title always cites at
|
|
13
|
+
* least one human `user/message` seq, and an explicit user rename cites none
|
|
14
|
+
* — `messageSeqs` is empty iff `source.kind` is `user`. Provider revisions
|
|
15
|
+
* are validated by the service before their append; this checks the durable
|
|
16
|
+
* relationship every appended `session/title` event must keep, whichever
|
|
17
|
+
* writer produced it.
|
|
18
|
+
*/
|
|
19
|
+
const install = Object.assign((ctx, fail) => {
|
|
20
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
21
|
+
if (eventName !== "session/event") return;
|
|
22
|
+
const [, event] = args;
|
|
23
|
+
if (event.type !== "session/title") return;
|
|
24
|
+
const { source, messageSeqs } = event.data;
|
|
25
|
+
if (messageSeqs.length === 0 !== (source.kind === "user")) {
|
|
26
|
+
const requirement = source.kind === "user" ? "cite no message seqs" : "cite at least one message seq";
|
|
27
|
+
fail(`session/title event ${String(event.seq)} with source "${source.kind}" must ${requirement}; got ${String(messageSeqs.length)}`);
|
|
28
|
+
}
|
|
29
|
+
}, { global: true });
|
|
30
|
+
}, { inject: ["sessions"] });
|
|
31
|
+
/**
|
|
32
|
+
* Register this package's invariant companion.
|
|
33
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
34
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
35
|
+
*/
|
|
36
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
37
|
+
//#endregion
|
|
38
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of the title domain: a pure re-export of the package's
|
|
3
|
+
* types outlet. Client code imports ONLY the client namespace (repo
|
|
4
|
+
* discipline), so `./client` projects the same single-source content
|
|
5
|
+
* `./types` serves to host consumers — zero duplication.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-session-title/client
|
|
8
|
+
*/
|
|
9
|
+
export type * from './types.ts';
|
|
10
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of the title domain: a pure re-export of the package's
|
|
3
|
+
* types outlet. Client code imports ONLY the client namespace (repo
|
|
4
|
+
* discipline), so `./client` projects the same single-source content
|
|
5
|
+
* `./types` serves to host consumers — zero duplication.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-session-title/client
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=client.js.map
|