@hydraharness/harness-session-title 0.1.1-rc.10

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 DeepSeek
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.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @hydraharness/harness-session-title
2
+
3
+ Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
4
+
5
+ Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
6
+
7
+ ## Service: `SessionTitleService` (ctx key: `sessionTitle`)
8
+
9
+ - `get(session)` folds the latest accepted title from a live or replayed log.
10
+ - `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event.
11
+ - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
12
+ - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
13
+
14
+ Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
15
+
16
+ Forks inherit title events in their seed unchanged. The first-prompt cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
17
+
18
+ ## Configuration
19
+
20
+ All limits are required; the library supplies no defaults.
21
+
22
+ | Key | Contract |
23
+ |---|---|
24
+ | `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
25
+ | `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
26
+ | `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
27
+
28
+ ## Provider contract
29
+
30
+ A provider supplies a branded stable id, automatic mode (`first-prompt` or `all-prompts`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and the optional provider/model route used to generate it. The service normalizes and validates the result before it becomes durable.
31
+
32
+ See the [session-title data structures](../../../docs/subsystems/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).
33
+
34
+ ## Model Experience
35
+
36
+ ### Session title state
37
+
38
+ #### What the model sees
39
+
40
+ Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix.
41
+
42
+ #### Token effect
43
+
44
+ The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package.
45
+
46
+ #### KV Cache effect
47
+
48
+ None for the main request; title events do not change its reconstructed content or cache key.
49
+
50
+ ## Known Limitations and Deferred Work
51
+
52
+ - Title deletion (unpinning back to automatic titles without an explicit `refresh`), search, and list indexing are outside this service.
53
+ - The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.
package/lib/index.js ADDED
@@ -0,0 +1,584 @@
1
+ import { Service } from "@hydraharness/cordis";
2
+ import z from "@hydraharness/schemastery";
3
+ import { z as z$1 } from "zod";
4
+ import { assertNever, deepFreeze, isAgentLoopRequest } from "@hydraharness/harness-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-prompt 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 @hydraharness/harness-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
+ const titleSchema = z$1.union([z$1.string().min(1), z$1.null()]);
179
+ projectionCtx.sessionProjections.register({
180
+ key: "title",
181
+ stateSchema: titleSchema,
182
+ init: () => null,
183
+ apply: (state, event) => event.type === "session/title" ? event.data.title : state,
184
+ wire: {
185
+ viewSchema: titleSchema,
186
+ view: (state) => state
187
+ },
188
+ stateVersion: 1
189
+ });
190
+ });
191
+ ctx.on("session/event", (session, event) => {
192
+ switch (event.type) {
193
+ case "user/message":
194
+ this.onUserMessage(session, event);
195
+ break;
196
+ case "request/header":
197
+ this.onRequestHeader(session, event);
198
+ break;
199
+ default: break;
200
+ }
201
+ });
202
+ ctx.on("llm/stream", (options, next) => {
203
+ this.onMainRequest(options);
204
+ return next();
205
+ }, {
206
+ global: true,
207
+ prepend: true
208
+ });
209
+ ctx.on("session/disposed", (session) => {
210
+ const state = this.work.get(session);
211
+ if (state === void 0) return;
212
+ state.active?.controller.abort(/* @__PURE__ */ new Error("session disposed during title generation"));
213
+ this.work.delete(session);
214
+ });
215
+ }
216
+ /**
217
+ * Read the latest folded title from one live or replayed session.
218
+ * @param session - session whose log is the title source of truth.
219
+ * @returns latest title snapshot, or `undefined` before eligible input.
220
+ */
221
+ get(session) {
222
+ return foldSessionTitle(session.events);
223
+ }
224
+ /**
225
+ * Accept an explicit user title. Appends a `session/title` event with the
226
+ * `user` source, which pins the title: in-flight automatic generation is
227
+ * superseded and later user messages schedule none (an explicit
228
+ * {@link SessionTitleService.refresh} remains the deliberate unpin).
229
+ * @param session - exact live session to rename.
230
+ * @param title - raw user input; normalized before acceptance.
231
+ * @returns the accepted title snapshot.
232
+ * @throws {SessionTitleInvalidError} when the title normalizes to empty.
233
+ * @throws {Error} when the session is not live or the service is disposed.
234
+ */
235
+ rename(session, title) {
236
+ this.assertServiceActive();
237
+ if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
238
+ const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes);
239
+ if (normalized.length === 0) throw new SessionTitleInvalidError("session title must contain visible characters");
240
+ const state = this.stateFor(session);
241
+ this.supersede(state, "user rename superseded automatic title generation");
242
+ session.append("session/title", {
243
+ title: normalized,
244
+ messageSeqs: [],
245
+ source: { kind: "user" }
246
+ });
247
+ const snapshot = this.get(session);
248
+ /* v8 ignore next -- unreachable: the append above just committed a session/title event. */
249
+ if (snapshot === void 0) throw new Error("renamed title failed to fold");
250
+ return snapshot;
251
+ }
252
+ /**
253
+ * Explicitly retry the registered provider, or materialize the built-in
254
+ * fallback when no provider is registered.
255
+ * @param session - exact live session to refresh.
256
+ * @param signal - optional caller cancellation.
257
+ * @returns latest accepted title, or `undefined` when no eligible text exists.
258
+ */
259
+ async refresh(session, signal) {
260
+ signal?.throwIfAborted();
261
+ this.assertServiceActive();
262
+ if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
263
+ const registration = this.registration;
264
+ const messages = collectSessionTitleMessages(session.events);
265
+ const latest = messages.at(-1);
266
+ if (registration === void 0 || registration.closing || latest === void 0) {
267
+ const current = this.get(session);
268
+ const [first] = messages;
269
+ if (current?.source.kind === "user" && first !== void 0) {
270
+ this.appendFallback(session, first);
271
+ signal?.throwIfAborted();
272
+ return this.get(session);
273
+ }
274
+ const fallback = await this.ensureFallback(session);
275
+ signal?.throwIfAborted();
276
+ return fallback;
277
+ }
278
+ const state = this.stateFor(session);
279
+ const revision = this.supersede(state, "explicit title refresh superseded older generation");
280
+ const work = this.activate({
281
+ registration,
282
+ revision,
283
+ throughSeq: latest.seq
284
+ }, state, signal);
285
+ const config = session.requestHeader()?.config;
286
+ const route = config === void 0 ? void 0 : {
287
+ provider: config.provider,
288
+ model: config.model
289
+ };
290
+ return this.startProvider(session, work, route);
291
+ }
292
+ /**
293
+ * Register the sole optional title provider. Disposal aborts its pending and
294
+ * active work before another provider may register.
295
+ * @param provider - provider identity, cadence, and generation function.
296
+ * @returns exact Cordis effect disposer, which settles after active calls quiesce.
297
+ */
298
+ register(provider) {
299
+ this.validateProvider(provider);
300
+ if (this.registration !== void 0) throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`);
301
+ const registration = {
302
+ provider,
303
+ active: /* @__PURE__ */ new Set(),
304
+ closing: false
305
+ };
306
+ return this.ctx.effect(function* () {
307
+ this.registration = registration;
308
+ yield async () => {
309
+ registration.closing = true;
310
+ for (const state of this.work.values()) {
311
+ if (state.pending?.registration === registration) delete state.pending;
312
+ if (state.active?.registration === registration) state.active.controller.abort(/* @__PURE__ */ new Error(`session-title provider "${provider.id}" was disposed`));
313
+ }
314
+ await this.drain(registration.active);
315
+ if (this.registration === registration) this.registration = void 0;
316
+ };
317
+ }.bind(this), "sessionTitle.register()");
318
+ }
319
+ /** Schedule fallback creation and any provider cadence for one eligible event. */
320
+ onUserMessage(session, event) {
321
+ if (!this.serviceActive()) return;
322
+ if (event.data.source.kind !== "user" || collectSessionTitleMessages([event]).length === 0) return;
323
+ if (this.get(session)?.source.kind === "user") return;
324
+ const registration = this.registration;
325
+ if (registration !== void 0 && !registration.closing) {
326
+ const messages = collectSessionTitleMessages(session.events, event.seq);
327
+ if (registration.provider.automatic === "all-prompts" || session.header.parentSession === void 0 && messages.length === 1 && this.get(session) === void 0) {
328
+ const state = this.stateFor(session);
329
+ state.pending = {
330
+ registration,
331
+ revision: this.supersede(state, "newer user message superseded title generation"),
332
+ throughSeq: event.seq
333
+ };
334
+ }
335
+ }
336
+ this.defer(async () => {
337
+ try {
338
+ await this.ensureFallback(session);
339
+ } catch (error) {
340
+ if (!this.serviceActive()) return;
341
+ this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`);
342
+ }
343
+ });
344
+ }
345
+ /** Start pending automatic work only after its exact main-request route is logged. */
346
+ onRequestHeader(session, event) {
347
+ if (!this.serviceActive()) return;
348
+ const state = this.work.get(session);
349
+ const pending = state?.pending;
350
+ if (state === void 0 || pending === void 0 || pending.throughSeq >= event.seq) return;
351
+ const route = {
352
+ provider: event.data.header.config.provider,
353
+ model: event.data.header.config.model
354
+ };
355
+ this.startPending(session, state, pending, route);
356
+ }
357
+ /** Start unchanged-route work from the marked loop request after its header fold is current. */
358
+ onMainRequest(options) {
359
+ if (!this.serviceActive() || options.sessionId === void 0 || !isAgentLoopRequest(options)) return;
360
+ const session = this.ctx.sessions.get(options.sessionId);
361
+ const state = session === void 0 ? void 0 : this.work.get(session);
362
+ const pending = state?.pending;
363
+ if (session === void 0 || state === void 0 || pending === void 0) return;
364
+ const boundary = session.events.findLast((event) => event.type === "step/start" || event.type === "step/end");
365
+ const route = session.requestHeader()?.config;
366
+ if (boundary?.type !== "step/start" || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return;
367
+ this.startPending(session, state, pending, {
368
+ provider: options.provider,
369
+ model: options.model
370
+ });
371
+ }
372
+ /** Consume one pending revision and schedule its non-blocking provider call. */
373
+ startPending(session, state, pending, route) {
374
+ delete state.pending;
375
+ this.defer(async () => {
376
+ if (this.registration !== pending.registration || pending.registration.closing || this.work.get(session) !== state || state.revision !== pending.revision) return;
377
+ const work = this.activate(pending, state);
378
+ try {
379
+ await this.startProvider(session, work, route);
380
+ } catch (error) {
381
+ if (work.signal.aborted || !this.serviceActive()) return;
382
+ this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`);
383
+ }
384
+ });
385
+ }
386
+ /** Start one tracked provider call after publishing its active revision. */
387
+ startProvider(session, work, route) {
388
+ const run = Promise.resolve().then(() => this.runProvider(session, work, route));
389
+ return this.track(run, work.registration);
390
+ }
391
+ /** Execute and accept one current provider revision. */
392
+ async runProvider(session, work, route) {
393
+ try {
394
+ this.assertCurrent(session, work);
395
+ await this.ensureFallback(session);
396
+ this.assertCurrent(session, work);
397
+ const messages = collectSessionTitleMessages(session.events, work.throughSeq);
398
+ const result = await work.registration.provider.generate({
399
+ session,
400
+ messages,
401
+ ...route === void 0 ? {} : { route },
402
+ signal: work.signal
403
+ });
404
+ this.assertCurrent(session, work);
405
+ const accepted = this.validateResult(result, messages);
406
+ session.append("session/title", {
407
+ title: accepted.title,
408
+ messageSeqs: [...accepted.messageSeqs],
409
+ source: {
410
+ kind: "provider",
411
+ provider: work.registration.provider.id,
412
+ ...accepted.model === void 0 ? {} : { model: accepted.model }
413
+ }
414
+ });
415
+ return this.get(session);
416
+ } finally {
417
+ const state = this.work.get(session);
418
+ if (state?.active === work) delete state.active;
419
+ }
420
+ }
421
+ /** Validate and normalize provider output against the supplied message snapshot. */
422
+ validateResult(result, messages) {
423
+ if (result === null || typeof result !== "object") throw new Error("session-title provider returned an invalid result");
424
+ const candidate = result;
425
+ if (typeof candidate.title !== "string") throw new Error("session-title provider title must be a string");
426
+ const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes);
427
+ if (title.length === 0) throw new Error("session-title provider returned an empty title");
428
+ if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) throw new Error("session-title provider must identify at least one source message seq");
429
+ const messageSeqs = [];
430
+ const order = new Map(messages.map((message, index) => [message.seq, index]));
431
+ let previous = -1;
432
+ for (const seq of candidate.messageSeqs) {
433
+ if (typeof seq !== "number") throw new Error("session-title provider messageSeqs must be unique, ordered seqs from the request");
434
+ const index = order.get(seq);
435
+ 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");
436
+ messageSeqs.push(seq);
437
+ previous = index;
438
+ }
439
+ const modelCandidate = candidate.model;
440
+ let model;
441
+ if (modelCandidate !== void 0) {
442
+ if (modelCandidate === null || typeof modelCandidate !== "object") throw new Error("session-title provider result model must contain non-empty provider and model strings");
443
+ const record = modelCandidate;
444
+ 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");
445
+ model = {
446
+ provider: record.provider,
447
+ model: record.model
448
+ };
449
+ }
450
+ return {
451
+ title,
452
+ messageSeqs,
453
+ ...model === void 0 ? {} : { model }
454
+ };
455
+ }
456
+ /** Fail a completion whose provider, revision, session, or signal is stale. */
457
+ assertCurrent(session, work) {
458
+ this.assertServiceActive();
459
+ work.signal.throwIfAborted();
460
+ const state = this.work.get(session);
461
+ /* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
462
+ * the work signal before changing this state. */
463
+ 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");
464
+ }
465
+ /** Create and publish an active provider call from one fixed revision. */
466
+ activate(pending, state, upstream) {
467
+ const controller = new AbortController();
468
+ const signal = upstream === void 0 ? AbortSignal.any([controller.signal, this.lifetime.signal]) : AbortSignal.any([
469
+ controller.signal,
470
+ this.lifetime.signal,
471
+ upstream
472
+ ]);
473
+ const work = {
474
+ ...pending,
475
+ controller,
476
+ signal
477
+ };
478
+ state.active = work;
479
+ return work;
480
+ }
481
+ /** Abort older active work and reserve the next session-local revision. */
482
+ supersede(state, reason) {
483
+ state.active?.controller.abort(new Error(reason));
484
+ delete state.pending;
485
+ state.revision += 1;
486
+ return state.revision;
487
+ }
488
+ /** Return mutable work state for one session. */
489
+ stateFor(session) {
490
+ let state = this.work.get(session);
491
+ if (state === void 0) {
492
+ state = { revision: 0 };
493
+ this.work.set(session, state);
494
+ }
495
+ return state;
496
+ }
497
+ /** Queue detached service work and retain it through service disposal. */
498
+ defer(task) {
499
+ const run = Promise.resolve().then(async () => {
500
+ if (!this.serviceActive()) return;
501
+ await task();
502
+ });
503
+ this.track(run);
504
+ }
505
+ /** Retain one promise until settlement for service and optional provider teardown. */
506
+ track(run, registration) {
507
+ this.inFlight.add(run);
508
+ registration?.active.add(run);
509
+ const settled = () => {
510
+ this.inFlight.delete(run);
511
+ registration?.active.delete(run);
512
+ };
513
+ run.then(settled, settled);
514
+ return run;
515
+ }
516
+ /** Await every current and settling promise in one lifecycle registry. */
517
+ async drain(active) {
518
+ while (active.size > 0) await Promise.allSettled([...active]);
519
+ }
520
+ /** Whether the owning plugin fiber can still start or commit title work. */
521
+ serviceActive() {
522
+ return !this.lifetime.signal.aborted && this.ownerFiber.uid !== null && this.ownerFiber.state === 2;
523
+ }
524
+ /** Reject work once the owning plugin fiber has begun unloading. */
525
+ assertServiceActive() {
526
+ if (!this.serviceActive()) throw new Error("session-title service disposed");
527
+ }
528
+ /** Reject malformed provider registrations before publishing an effect. */
529
+ validateProvider(provider) {
530
+ if (provider === null || typeof provider !== "object") throw new Error("session-title provider must be an object");
531
+ const candidate = provider;
532
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) throw new Error("session-title provider id must be a non-empty string");
533
+ if (candidate.automatic !== "first-prompt" && candidate.automatic !== "all-prompts") throw new Error("session-title provider automatic mode is invalid");
534
+ if (typeof candidate.generate !== "function") throw new Error(`session-title provider "${candidate.id}" requires generate()`);
535
+ }
536
+ /**
537
+ * Derive and append the deterministic fallback title over whatever stands
538
+ * (the refresh unpin path: overwriting a pinned user title is the point).
539
+ * Synchronous on purpose — no await may separate derivation from append, so
540
+ * it needs neither ensureFallback's in-flight dedup nor its liveness
541
+ * re-check. An underivable fallback (empty after the caps) appends nothing.
542
+ */
543
+ appendFallback(session, first) {
544
+ const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
545
+ if (title.length === 0) return;
546
+ session.append("session/title", {
547
+ title,
548
+ messageSeqs: [first.seq],
549
+ source: { kind: "fallback" }
550
+ });
551
+ }
552
+ /** Create the first deterministic fallback if the session still lacks a title. */
553
+ async ensureFallback(session) {
554
+ this.assertServiceActive();
555
+ const current = this.get(session);
556
+ if (current !== void 0) return current;
557
+ const [first] = collectSessionTitleMessages(session.events);
558
+ if (first === void 0) return void 0;
559
+ const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
560
+ if (title.length === 0) return void 0;
561
+ const state = this.stateFor(session);
562
+ if (state.fallback !== void 0) return state.fallback;
563
+ const fallback = Promise.resolve().then(() => {
564
+ this.assertServiceActive();
565
+ if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
566
+ const accepted = this.get(session);
567
+ if (accepted !== void 0) return accepted;
568
+ session.append("session/title", {
569
+ title,
570
+ messageSeqs: [first.seq],
571
+ source: { kind: "fallback" }
572
+ });
573
+ return this.get(session);
574
+ });
575
+ state.fallback = fallback;
576
+ try {
577
+ return await fallback;
578
+ } finally {
579
+ delete state.fallback;
580
+ }
581
+ }
582
+ };
583
+ //#endregion
584
+ export { SessionTitleInvalidError, SessionTitleProviderId, SessionTitleService, SessionTitleService as default, collectSessionTitleMessages, fallbackSessionTitle, foldSessionTitle, normalizeSessionTitle, truncateTitleUtf8 };