@deepseek-ai/dsh-session-title 0.1.1-rc.2 → 0.1.2-alpha.2

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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/session/session-title/README.md
5
- README.md: e923bd9700180214f235c4971d4b020565bfee43
6
- README.zh.md: 6b1eef269dea65c66c1e9a70ce9469c66d192e84
5
+ README.md: debc737e3f572f15856f7f78a58abe8532ae15dc
6
+ README.zh.md: cf8079cd5566023f3dc1da8480eeec5327eebb5a
package/README.md CHANGED
@@ -1,38 +1,117 @@
1
+ ---
2
+ description: "Log-backed session titles for users and maintainers choosing a title source, configuring the service, or debugging title state."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-session-title
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- 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.
10
+ ## Summary
11
+
12
+ `dsh-session-title` gives every session a title clients can display: a deterministic fallback from the first eligible human message, an optional asynchronous provider (such as a model-backed one), or an explicit user rename. Every accepted revision is a log-only `session/title` event, so titles survive replay, resume, and paging exactly like any other session event and never enter the model surface. The service owns scheduling and acceptance; the optional provider owns generation. Automatic work never delays the main agent response, and a newer revision supersedes older work. Configuration and title sources come first; the implementation internals live in a collapsible developer section below.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount the service to give sessions titles that clients can display and that never reach the model. The common path is explicit: load the session store, mount the service with its required limits, and optionally mount one provider plugin.
29
+
30
+ ### Choosing a title source
31
+
32
+ Titles come from three sources, newest wins. The built-in fallback derives from the first eligible human message's leading words within the configured caps; a registered provider generates a title over eligible messages; an explicit `rename()` accepts a user-supplied title. Only text blocks from human `user/message` events are eligible, and empty or non-text prompts wait for later eligible input. A user-sourced latest title pins the session — later user messages schedule no automatic revision, and an explicit `refresh()` remains the deliberate unpin.
33
+
34
+ ### Minimal configuration
35
+
36
+ All limits are required; the library supplies no defaults. Mount the service with the three bounds:
37
+
38
+ ```yaml
39
+ - name: '@deepseek-ai/dsh-session'
40
+ - name: '@deepseek-ai/dsh-session-title'
41
+ config:
42
+ fallbackMaxWords: 8
43
+ fallbackMaxBytes: 96
44
+ maxTitleBytes: 120
45
+ ```
46
+
47
+ | Field | Default | Meaning |
48
+ |---|---|---|
49
+ | `fallbackMaxWords` | required | Maximum whitespace-delimited words in the deterministic fallback |
50
+ | `fallbackMaxBytes` | required | Maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes` |
51
+ | `maxTitleBytes` | required | Maximum UTF-8 bytes accepted from any source |
52
+
53
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-title) is the exhaustive source for every accepted field and its JSDoc.
54
+
55
+ ### Adding a provider
56
+
57
+ One optional asynchronous provider may be registered through `ctx.sessionTitle.register(provider)`; a second registration throws. The shipped model-backed providers are [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../session-title-all-prompts-llm/README.md), both using the shared [LLM generation policy](../session-title-llm/README.md). A provider starts only after a marked loop-built request's exact route matches the logged `request/header`, and a newer revision supersedes and aborts older work.
58
+
59
+ ### Reading titles
6
60
 
7
- 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.
61
+ `get(session)` reads the latest folded title from one live or replayed session, and `foldSessionTitle(events)` is the pure fold over a log. The service requires `ctx.sessionProjections` and registers two units: the client-visible `title` unit (the accepted title string for client list rows) and the host-only `titleInput` unit, which folds the first and latest eligible messages plus their count so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time. An explicit `refresh(session)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages.
8
62
 
9
- ## Service: `SessionTitleService` (ctx key: `sessionTitle`)
63
+ ### Failures and recovery
10
64
 
11
- - `get(session)` folds the latest accepted title from a live or replayed log.
12
- - `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.
13
- - `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.
14
- - `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.
65
+ Automatic failures warn and retain the latest title; explicit `refresh()` rejects on provider error or caller cancellation, and cancellation does not roll back an already accepted fallback event. Automatic work never delays the main agent response, its late completion appends a standalone log-only event without opening a turn, and a stale completion cannot append. Forks inherit title events in their seed unchanged.
15
66
 
16
- 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.
67
+ -----
17
68
 
18
- 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.
69
+ <a id="understand-the-implementation"></a>
70
+ ## Understand the implementation
19
71
 
20
- ## Configuration
72
+ <details>
73
+ <summary>Implementation internals — click to expand</summary>
21
74
 
22
- All limits are required; the library supplies no defaults.
75
+ This section explains the title design; the observable behavior is fully covered in [Use this package](#use-this-package).
23
76
 
24
- | Key | Contract |
77
+ ### Design concept
78
+
79
+ Titles are durable, log-only state: every accepted revision is a `session/title` event, and `foldSessionTitle()` selects the latest, so a title survives replay, resume, and paging exactly like any other session event. The service owns scheduling, supersession, and acceptance; providers own generation.
80
+
81
+ ### Source map
82
+
83
+ | File | Role |
25
84
  |---|---|
26
- | `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
27
- | `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
28
- | `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
85
+ | [`src/index.ts`](src/index.ts) | Service: config, fold, fallback scheduling, provider registry, concurrency, `title` projection unit |
86
+ | [`src/normalize.ts`](src/normalize.ts) | Title-text cleaning, UTF-8-safe truncation, and the deterministic fallback |
87
+ | [`src/types.ts`](src/types.ts) | One home of the `title` projection-key declaration |
88
+
89
+ ### Lifecycle and concurrency
90
+
91
+ Per-session work state tracks a revision counter, an in-flight fallback, and pending and active provider work. A newer user message, provider disposal, session disposal, or explicit refresh aborts older work through an `AbortController`; a completion whose provider, revision, session, or signal is stale cannot append. Explicit refreshes reserve their revision before provider work; overlapping automatic and explicit fallback requests share one session-local in-flight append. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
92
+
93
+ ### Normalization
94
+
95
+ Accepted titles are cleaned of terminal control sequences, directional and invisible controls, and non-whitespace C0/C1 controls; whitespace is normalized, and truncation to the byte caps never splits a Unicode code point. The deterministic fallback takes the first eligible message's leading words within `fallbackMaxWords` and `fallbackMaxBytes`.
96
+
97
+ </details>
98
+
99
+ -----
29
100
 
30
- ## Provider contract
101
+ <a id="further-exploration"></a>
102
+ ## Further Exploration
31
103
 
32
- 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.
104
+ Read these pages when the service contract is not enough. They move from the subsystem reference to the model-backed providers that plug in here.
33
105
 
34
- 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).
106
+ - [Session title subsystem](../../../docs/subsystems/session-title.md) — durable title state and the provider vocabulary types.
107
+ - [Shared LLM title policy](../session-title-llm/README.md) — the model-backed generation helper both shipped providers use.
108
+ - [First-message title provider](../session-title-first-prompt-llm/README.md) — titles from the first eligible human message.
109
+ - [All-messages title provider](../session-title-all-prompts-llm/README.md) — titles from every eligible human message.
110
+ - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
35
111
 
112
+ -----
113
+
114
+ <a id="model-experience"></a>
36
115
  ## Model Experience
37
116
 
38
117
  ### Session title state
@@ -51,5 +130,20 @@ None for the main request; title events do not change its reconstructed content
51
130
 
52
131
  ## Known Limitations and Deferred Work
53
132
 
54
- - Title deletion (unpinning back to automatic titles without an explicit `refresh`), search, and list indexing are outside this service.
55
- - 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.
133
+ <a id="known-limitations-and-deferred-work"></a>
134
+
135
+
136
+ These limits define what the title service does not provide. They are current package constraints.
137
+
138
+ - **No title deletion, search, or list indexing** — unpinning back to automatic titles without an explicit `refresh`, search, and list indexing are outside this service.
139
+ - **At most one provider** — the registry deliberately accepts a single implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.
140
+
141
+ <a id="dev-note"></a>
142
+ ### Dev Note
143
+
144
+ <details>
145
+ <summary>Working context for maintainers — click to expand</summary>
146
+
147
+ None.
148
+
149
+ </details>
package/README.zh.md CHANGED
@@ -1,43 +1,122 @@
1
+ ---
2
+ description: "面向用户与维护者的日志会话标题说明,用于选择标题来源、配置服务或排查标题状态。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-session-title
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件;`foldSessionTitle()` 与 `ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。
10
+ ## 概述
11
+
12
+ `dsh-session-title` 为每个会话提供客户端可以显示的标题:来自第一条符合条件用户消息的确定性回退、一个可选异步提供方(例如模型支持的提供方),或显式用户重命名。每个已接受的修订都是仅写入日志的 `session/title` 事件,因此标题像任何其他会话事件一样在回放、恢复与分页中存活,且绝不进入模型可见面。服务拥有调度与接受;可选提供方负责生成。自动工作绝不会延迟主 agent 响应,较新的修订会取代旧工作。配置与标题来源在前;实现内部细节放在下方可折叠的开发者章节中。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 挂载服务,让会话获得客户端可以显示、且绝不触及模型的标题。常用路径是显式的:加载会话存储、以必填上限挂载服务,并可选挂载一个提供方插件。
29
+
30
+ ### 选择标题来源
31
+
32
+ 标题来自三个来源,最新者胜出。内置回退在配置上限内从第一条符合条件用户消息的开头若干词派生;已注册提供方对符合条件的消息生成标题;显式 `rename()` 接受用户提供的标题。只有人类 `user/message` 事件中的文本块符合条件,空提示词或非文本提示词会等待后续符合条件的输入。用户来源的最新标题会钉住会话——后续用户消息不再安排自动修订,显式 `refresh()` 仍是有意的解钉手段。
33
+
34
+ ### 最小配置
35
+
36
+ 所有上限都是必填项;该库不提供默认值。以三个上限挂载服务:
37
+
38
+ ```yaml
39
+ - name: '@deepseek-ai/dsh-session'
40
+ - name: '@deepseek-ai/dsh-session-title'
41
+ config:
42
+ fallbackMaxWords: 8
43
+ fallbackMaxBytes: 96
44
+ maxTitleBytes: 120
45
+ ```
46
+
47
+ | 字段 | 默认值 | 含义 |
48
+ |---|---|---|
49
+ | `fallbackMaxWords` | 必填 | 确定性回退中以空白分隔的最大词数 |
50
+ | `fallbackMaxBytes` | 必填 | 回退允许的最大 UTF-8 字节数;不得超过 `maxTitleBytes` |
51
+ | `maxTitleBytes` | 必填 | 接受任何来源标题的最大 UTF-8 字节数 |
52
+
53
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-title)是每个受支持字段及其 JSDoc 的穷尽式真源。
54
+
55
+ ### 添加提供方
56
+
57
+ 可选异步提供方可通过 `ctx.sessionTitle.register(provider)` 注册一个;第二次注册会立即抛出。随附的模型支持提供方是[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../session-title-all-prompts-llm/README.zh.md),两者都使用共享的 [LLM 生成策略](../session-title-llm/README.zh.md)。提供方只有在带标记、由循环构建的请求的确切路由与已记录 `request/header` 匹配时才启动,较新的修订会取代并中止旧工作。
58
+
59
+ ### 读取标题
6
60
 
7
- 只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断码点。空提示词和非文本提示词会等待后续符合条件的输入。
61
+ `get(session)` 从活跃或回放会话读取折叠出的最新标题,`foldSessionTitle(events)` 是对日志的纯折叠。服务要求 `ctx.sessionProjections` 并注册两个单元:客户端可见的 `title` 单元(供客户端列表行使用的已接受标题字符串)和仅供 host 使用的 `titleInput` 单元——后者折叠第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得。显式 `refresh(session)` 在需要时物化回退,然后对当前符合条件的消息显式运行已注册提供方。
8
62
 
9
- ## 服务:`SessionTitleService`(ctx 键:`sessionTitle`)
63
+ ### 失败与恢复
10
64
 
11
- - `get(session)` 从活跃或回放日志折叠最新已接受标题。
12
- - `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误或调用方取消都会导致返回的 Promise 被拒绝;取消不会回滚已接受的回退事件。
13
- - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。
14
- - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。
65
+ 自动失败会发出警告并保留最新标题;显式 `refresh()` 在提供方错误或调用方取消时拒绝,取消不会回滚已接受的回退事件。自动工作绝不会延迟主 agent 响应,其延迟完成会追加一个独立纯日志事件而不打开轮次,陈旧的完成结果无法追加。fork 出的会话会原样继承种子中的标题事件。
15
66
 
16
- 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
67
+ -----
17
68
 
18
- fork 出的会话会原样继承种子中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新修订。
69
+ <a id="understand-the-implementation"></a>
70
+ ## 理解实现
19
71
 
20
- ## 配置
72
+ <details>
73
+ <summary>实现细节——点击展开</summary>
21
74
 
22
- 所有上限都是必填项;该库不提供默认值。
75
+ 本节解释标题设计;可观察行为已在[使用本包](#use-this-package)中完整说明。
23
76
 
24
- | 键 | 约定 |
77
+ ### 设计理念
78
+
79
+ 标题是持久的、仅写入日志的状态:每个已接受的修订都是 `session/title` 事件,`foldSessionTitle()` 选择最新事件,因此标题像任何其他会话事件一样在回放、恢复与分页中存活。服务拥有调度、取代与接受;提供方负责生成。
80
+
81
+ ### 源码地图
82
+
83
+ | 文件 | 职责 |
25
84
  |---|---|
26
- | `fallbackMaxWords` | 确定性回退中以空白分隔的最大正整数词数。 |
27
- | `fallbackMaxBytes` | 回退允许的最大正整数 UTF-8 字节数;不得超过 `maxTitleBytes`。 |
28
- | `maxTitleBytes` | 接受任何来源标题的最大正整数 UTF-8 字节数。 |
85
+ | [`src/index.ts`](src/index.ts) | 服务:配置、折叠、回退调度、提供方注册表、并发、`title` 投影单元 |
86
+ | [`src/normalize.ts`](src/normalize.ts) | 标题文本清洗、UTF-8 安全截断与确定性回退 |
87
+ | [`src/types.ts`](src/types.ts) | `title` 投影键声明的唯一归属 |
88
+
89
+ ### 生命周期与并发
90
+
91
+ 每个会话的工作状态维护一个修订计数器、一个进行中的回退,以及待处理与活跃的提供方工作。较新的用户消息、提供方 dispose(资源释放)、会话 dispose 或显式刷新都会通过 `AbortController` 中止旧工作;提供方、修订、会话或信号已陈旧的完成结果无法追加。显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算。
92
+
93
+ ### 规范化
94
+
95
+ 已接受标题会清除终端控制序列、方向性与不可见控制符以及非空白的 C0/C1 控制符;空白被规范化,按字节上限截断时绝不切断 Unicode 码点。确定性回退在 `fallbackMaxWords` 与 `fallbackMaxBytes` 内取第一条符合条件消息的开头若干词。
96
+
97
+ </details>
98
+
99
+ -----
29
100
 
30
- ## 提供方约定
101
+ <a id="further-exploration"></a>
102
+ ## 进一步探索
31
103
 
32
- 提供方会提供带品牌类型的稳定 id、自动模式(`first-prompt` 或 `all-prompts`)和 `generate(request)`。请求携带活跃会话、截至一次固定修订的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中互不重复且有序的来源消息 seq,以及生成该标题时使用的可选提供方/模型路由。服务会在结果持久保存前进行规范化和验证。
104
+ 当服务约定不够用时阅读以下页面。它们从子系统参考逐步进入在此插拔的模型支持提供方。
33
105
 
34
- 参见[会话标题数据结构](../../../docs/subsystems/session-title.zh.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md)。
106
+ - [会话标题子系统](../../../docs/subsystems/session-title.zh.md)——持久标题状态与提供方词汇类型。
107
+ - [共享 LLM 标题策略](../session-title-llm/README.zh.md)——两个随附提供方共用的模型生成辅助模块。
108
+ - [首消息标题提供方](../session-title-first-prompt-llm/README.zh.md)——根据第一条符合条件的用户消息生成标题。
109
+ - [全消息标题提供方](../session-title-all-prompts-llm/README.zh.md)——根据所有符合条件的用户消息生成标题。
110
+ - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
35
111
 
112
+ -----
113
+
114
+ <a id="model-experience"></a>
36
115
  ## 模型体验
37
116
 
38
117
  ### 会话标题状态
39
118
 
40
- #### 模型看到的内容
119
+ #### 模型看到什么
41
120
 
42
121
  无。`session/title` 只写入日志,绝不会进入会话接口、`deriveMessages()`、系统提示词、工具 schema 或请求前缀。
43
122
 
@@ -47,9 +126,24 @@ fork 出的会话会原样继承种子中的标题事件。首消息节奏不会
47
126
 
48
127
  #### KV Cache 影响
49
128
 
50
- 不影响主请求;标题事件不会改变重建内容或缓存键。
129
+ 不影响主请求;标题事件不会改变其重建内容或缓存键。
130
+
131
+ ## 已知限制与延期工作
132
+
133
+ <a id="known-limitations-and-deferred-work"></a>
134
+
135
+
136
+ 这些限制说明标题服务不提供什么。它们是当前包约束。
137
+
138
+ - **没有标题删除、搜索或列表索引**——不经显式 `refresh` 就解钉回自动标题、搜索与列表索引不属于此服务。
139
+ - **至多一个提供方**——注册表有意只接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。
140
+
141
+ <a id="dev-note"></a>
142
+ ### 开发备注
143
+
144
+ <details>
145
+ <summary>维护者的工作上下文——点击展开</summary>
51
146
 
52
- ## 已知限制与暂缓事项
147
+ 无。
53
148
 
54
- - 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。
55
- - 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。
149
+ </details>
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { z as z$1 } from "zod";
4
- import { assertNever, deepFreeze, isAgentLoopRequest } from "@deepseek-ai/dsh-llm";
4
+ import { isAgentLoopRequest } from "@deepseek-ai/dsh-llm";
5
+ import { assertNever, deepFreeze } from "@deepseek-ai/dsh-util-values";
5
6
  //#region lib/types/normalize.js
6
7
  /** Title text normalization and UTF-8-safe truncation. */
7
8
  /** Operating-system-command escape sequences, including unterminated tails. */
@@ -84,26 +85,100 @@ function SessionTitleProviderId(id) {
84
85
  var SessionTitleInvalidError = class extends Error {
85
86
  name = "SessionTitleInvalidError";
86
87
  };
88
+ /** Extract one eligible human text message from a session event. */
89
+ function sessionTitleUserMessageOf(event) {
90
+ if (event.type !== "user/message" || event.data.source.kind !== "user") return void 0;
91
+ const text = event.data.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
92
+ if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) return void 0;
93
+ return {
94
+ seq: event.seq,
95
+ text
96
+ };
97
+ }
98
+ /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
99
+ function copySessionTitleSource(source) {
100
+ switch (source.kind) {
101
+ case "fallback": return { kind: "fallback" };
102
+ case "provider": return {
103
+ kind: "provider",
104
+ provider: source.provider,
105
+ ...source.model === void 0 ? {} : { model: { ...source.model } }
106
+ };
107
+ case "user": return { kind: "user" };
108
+ /* v8 ignore next -- closed-union exhaustiveness guard */
109
+ default: return assertNever(source, "SessionTitleSource");
110
+ }
111
+ }
112
+ /** Validate one positive integer configuration field. */
113
+ function assertPositiveInteger(name, value) {
114
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`session-title: ${name} must be a positive integer`);
115
+ }
116
+ /**
117
+ * Convert title projection state into an immutable snapshot.
118
+ * @param state - the title unit's folded state.
119
+ * @returns the immutable snapshot.
120
+ */
121
+ function titleSnapshotFromState(state) {
122
+ return deepFreeze({
123
+ title: state.title,
124
+ messageSeqs: [...state.messageSeqs],
125
+ source: copySessionTitleSource(state.source),
126
+ eventSeq: state.eventSeq,
127
+ updatedAt: state.updatedAt
128
+ });
129
+ }
130
+ const EMPTY_TITLE_INPUT = {
131
+ first: null,
132
+ count: 0,
133
+ lastSeq: null
134
+ };
135
+ const sessionTitleUserMessageSchema = z$1.object({
136
+ seq: z$1.number().int().nonnegative(),
137
+ text: z$1.string()
138
+ }).strict();
139
+ const titleInputStateSchema = z$1.object({
140
+ first: sessionTitleUserMessageSchema.nullable(),
141
+ count: z$1.number().int().nonnegative(),
142
+ lastSeq: z$1.number().int().nonnegative().nullable()
143
+ }).strict().superRefine((state, context) => {
144
+ const empty = state.first === null && state.lastSeq === null && state.count === 0;
145
+ const populated = state.first !== null && state.lastSeq !== null && state.count > 0 && state.first.seq <= state.lastSeq;
146
+ if (!empty && !populated) context.addIssue({
147
+ code: "custom",
148
+ message: "title input state must pair its count with first and last message seqs"
149
+ });
150
+ });
87
151
  /**
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.
152
+ * Collect eligible human text messages from a session log, in seq order.
153
+ * The full eligible prefix is only materialized for one provider generation,
154
+ * so it is scanned from the log at execution time rather than retained by
155
+ * the O(1) `titleInput` projection.
156
+ * @param events - the session event log.
157
+ * @param throughSeq - optional inclusive upper seq bound.
91
158
  * @returns eligible messages with exact source seqs.
92
159
  */
93
160
  function collectSessionTitleMessages(events, throughSeq) {
94
161
  const messages = [];
95
162
  for (const event of events) {
96
163
  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
- });
164
+ const message = sessionTitleUserMessageOf(event);
165
+ if (message !== void 0) messages.push(message);
104
166
  }
105
167
  return messages;
106
168
  }
169
+ const titleViewSchema = z$1.string().min(1).nullable();
170
+ /** Latest logged title text and its client view. */
171
+ const titleProjectionDefinition = {
172
+ key: "title",
173
+ stateVersion: 1,
174
+ stateSchema: titleViewSchema,
175
+ init: () => null,
176
+ apply: (state, event) => event.type === "session/title" ? event.data.title : state,
177
+ wire: {
178
+ viewSchema: titleViewSchema,
179
+ view: (state) => state
180
+ }
181
+ };
107
182
  /**
108
183
  * Fold the latest logged title without consulting mutable metadata.
109
184
  * @param events - live or persisted session log.
@@ -112,35 +187,17 @@ function collectSessionTitleMessages(events, throughSeq) {
112
187
  function foldSessionTitle(events) {
113
188
  const event = events.findLast((item) => item.type === "session/title");
114
189
  if (event === void 0) return void 0;
115
- return deepFreeze({
190
+ return titleSnapshotFromState({
116
191
  title: event.data.title,
117
- messageSeqs: [...event.data.messageSeqs],
118
- source: copySessionTitleSource(event.data.source),
192
+ messageSeqs: event.data.messageSeqs,
193
+ source: event.data.source,
119
194
  eventSeq: event.seq,
120
195
  updatedAt: event.time
121
196
  });
122
197
  }
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
198
  /** Log-backed title fold plus asynchronous fallback generation. */
142
199
  var SessionTitleService = class extends Service {
143
- static inject = ["sessions"];
200
+ static inject = ["sessions", "sessionProjections"];
144
201
  static Config = z.object({
145
202
  fallbackMaxWords: z.number().step(1).min(1).required(),
146
203
  fallbackMaxBytes: z.number().step(1).min(1).required(),
@@ -174,19 +231,21 @@ var SessionTitleService = class extends Service {
174
231
  await this.drain(this.inFlight);
175
232
  this.work.clear();
176
233
  }, "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
- });
234
+ ctx.sessionProjections.register(titleProjectionDefinition);
235
+ ctx.sessionProjections.register({
236
+ key: "titleInput",
237
+ stateVersion: 3,
238
+ stateSchema: titleInputStateSchema,
239
+ init: () => EMPTY_TITLE_INPUT,
240
+ apply: (state, event) => {
241
+ const message = sessionTitleUserMessageOf(event);
242
+ if (message === void 0) return state;
243
+ return {
244
+ first: state.first ?? message,
245
+ count: state.count + 1,
246
+ lastSeq: message.seq
247
+ };
248
+ }
190
249
  });
191
250
  ctx.on("session/event", (session, event) => {
192
251
  switch (event.type) {
@@ -261,12 +320,11 @@ var SessionTitleService = class extends Service {
261
320
  this.assertServiceActive();
262
321
  if (this.ctx.sessions.get(session.id) !== session) throw new Error(`session "${session.id}" is not live in this store`);
263
322
  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) {
323
+ const input = this.titleInputOf(session);
324
+ if (registration === void 0 || registration.closing || input.lastSeq === null) {
267
325
  const current = this.get(session);
268
- const [first] = messages;
269
- if (current?.source.kind === "user" && first !== void 0) {
326
+ const first = input.first;
327
+ if (current?.source.kind === "user" && first !== null) {
270
328
  this.appendFallback(session, first);
271
329
  signal?.throwIfAborted();
272
330
  return this.get(session);
@@ -280,7 +338,7 @@ var SessionTitleService = class extends Service {
280
338
  const work = this.activate({
281
339
  registration,
282
340
  revision,
283
- throughSeq: latest.seq
341
+ throughSeq: input.lastSeq
284
342
  }, state, signal);
285
343
  const config = session.requestHeader()?.config;
286
344
  const route = config === void 0 ? void 0 : {
@@ -319,12 +377,12 @@ var SessionTitleService = class extends Service {
319
377
  /** Schedule fallback creation and any provider cadence for one eligible event. */
320
378
  onUserMessage(session, event) {
321
379
  if (!this.serviceActive()) return;
322
- if (event.data.source.kind !== "user" || collectSessionTitleMessages([event]).length === 0) return;
380
+ if (event.data.source.kind !== "user" || sessionTitleUserMessageOf(event) === void 0) return;
323
381
  if (this.get(session)?.source.kind === "user") return;
324
382
  const registration = this.registration;
325
383
  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) {
384
+ const count = this.titleInputOf(session).count;
385
+ if (registration.provider.automatic === "all-prompts" || session.header.parentSession === void 0 && count === 1 && this.get(session) === void 0) {
328
386
  const state = this.stateFor(session);
329
387
  state.pending = {
330
388
  registration,
@@ -361,9 +419,9 @@ var SessionTitleService = class extends Service {
361
419
  const state = session === void 0 ? void 0 : this.work.get(session);
362
420
  const pending = state?.pending;
363
421
  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");
422
+ const boundary = this.ctx.sessionProjections.stateOf(session, "turnBoundary")?.lastStepBoundary;
365
423
  const route = session.requestHeader()?.config;
366
- if (boundary?.type !== "step/start" || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return;
424
+ if (boundary?.kind !== "start" || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return;
367
425
  this.startPending(session, state, pending, {
368
426
  provider: options.provider,
369
427
  model: options.model
@@ -494,6 +552,9 @@ var SessionTitleService = class extends Service {
494
552
  }
495
553
  return state;
496
554
  }
555
+ titleInputOf(session) {
556
+ return this.ctx.sessionProjections.stateOf(session, "titleInput");
557
+ }
497
558
  /** Queue detached service work and retain it through service disposal. */
498
559
  defer(task) {
499
560
  const run = Promise.resolve().then(async () => {
@@ -554,8 +615,8 @@ var SessionTitleService = class extends Service {
554
615
  this.assertServiceActive();
555
616
  const current = this.get(session);
556
617
  if (current !== void 0) return current;
557
- const [first] = collectSessionTitleMessages(session.events);
558
- if (first === void 0) return void 0;
618
+ const first = this.titleInputOf(session).first;
619
+ if (first === null) return void 0;
559
620
  const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
560
621
  if (title.length === 0) return void 0;
561
622
  const state = this.stateFor(session);
@@ -581,4 +642,4 @@ var SessionTitleService = class extends Service {
581
642
  }
582
643
  };
583
644
  //#endregion
584
- export { SessionTitleInvalidError, SessionTitleProviderId, SessionTitleService, SessionTitleService as default, collectSessionTitleMessages, fallbackSessionTitle, foldSessionTitle, normalizeSessionTitle, truncateTitleUtf8 };
645
+ export { SessionTitleInvalidError, SessionTitleProviderId, SessionTitleService, SessionTitleService as default, fallbackSessionTitle, foldSessionTitle, normalizeSessionTitle, titleProjectionDefinition, truncateTitleUtf8 };
@@ -4,10 +4,11 @@
4
4
  */
5
5
  import { Context, Service } from '@deepseek-ai/cordis';
6
6
  import z from '@deepseek-ai/schemastery';
7
+ import { z as zod } from 'zod';
7
8
  import type { Branded } from '@deepseek-ai/dsh-brand';
8
9
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
9
- export type * from './types.ts';
10
- export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts';
10
+ export type { SessionTitleEventData, SessionTitleModelProvenance, SessionTitleSnapshot, SessionTitleSource, SessionTitleUserMessage, TitleProjection, } from './types.ts';
11
+ import type { SessionTitleEventData, SessionTitleModelProvenance, SessionTitleSnapshot, SessionTitleUserMessage } from './types.ts';
11
12
  /** Identifies one session-title provider registration. */
12
13
  export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>;
13
14
  /**
@@ -16,40 +17,7 @@ export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>;
16
17
  * @returns the same string with the session-title provider brand.
17
18
  */
18
19
  export declare function SessionTitleProviderId(id: string): SessionTitleProviderId;
19
- /** Exact auxiliary model route that produced a title. */
20
- export interface SessionTitleModelProvenance {
21
- /** Registered LLM provider route. */
22
- readonly provider: string;
23
- /** Provider model id. */
24
- readonly model: string;
25
- }
26
- /** Durable ownership record for an accepted session title. */
27
- export type SessionTitleSource = {
28
- readonly kind: 'fallback';
29
- } | {
30
- readonly kind: 'provider';
31
- readonly provider: SessionTitleProviderId;
32
- readonly model?: SessionTitleModelProvenance;
33
- } | {
34
- /** Explicit user rename: pins the title — automatic generation stops scheduling. */
35
- readonly kind: 'user';
36
- };
37
- /** Payload of the log-only `session/title` event. */
38
- export interface SessionTitleEventData {
39
- /** Normalized non-empty title text. */
40
- readonly title: string;
41
- /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
42
- readonly messageSeqs: number[];
43
- /** Whether the built-in fallback, a registered provider, or the user supplied the title. */
44
- readonly source: SessionTitleSource;
45
- }
46
- /** Latest folded title plus the title event's durable envelope facts. */
47
- export interface SessionTitleSnapshot extends SessionTitleEventData {
48
- /** Seq of the latest `session/title` event. */
49
- readonly eventSeq: number;
50
- /** Timestamp of the latest `session/title` event. */
51
- readonly updatedAt: number;
52
- }
20
+ export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts';
53
21
  /** Required deterministic fallback and accepted-title limits. */
54
22
  export interface Config {
55
23
  /** Maximum whitespace-delimited words in the built-in fallback. */
@@ -82,13 +50,6 @@ declare module '@deepseek-ai/dsh-session/types' {
82
50
  export declare class SessionTitleInvalidError extends Error {
83
51
  readonly name = "SessionTitleInvalidError";
84
52
  }
85
- /** One eligible human text message exposed to title providers. */
86
- export interface SessionTitleUserMessage {
87
- /** Source `user/message` event seq. */
88
- readonly seq: number;
89
- /** Exact concatenated text-block content. */
90
- readonly text: string;
91
- }
92
53
  /** Automatic generation cadence owned by a registered provider. */
93
54
  export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts';
94
55
  /** Immutable input supplied to one title-provider call. */
@@ -124,13 +85,18 @@ export interface SessionTitleProvider {
124
85
  */
125
86
  generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>;
126
87
  }
127
- /**
128
- * Collect human text-bearing user messages in log order.
129
- * @param events - session log or persisted replay.
130
- * @param throughSeq - optional inclusive event boundary.
131
- * @returns eligible messages with exact source seqs.
132
- */
133
- export declare function collectSessionTitleMessages(events: readonly SessionEvent[], throughSeq?: number): SessionTitleUserMessage[];
88
+ /** Latest logged title text and its client view. */
89
+ export declare const titleProjectionDefinition: {
90
+ key: "title";
91
+ stateVersion: number;
92
+ stateSchema: zod.ZodType<string | null, unknown, zod.core.$ZodTypeInternals<string | null, unknown>>;
93
+ init: () => null;
94
+ apply: (state: string | null, event: SessionEvent) => string | null;
95
+ wire: {
96
+ viewSchema: zod.ZodType<string | null, unknown, zod.core.$ZodTypeInternals<string | null, unknown>>;
97
+ view: (state: string | null) => string | null;
98
+ };
99
+ };
134
100
  /**
135
101
  * Fold the latest logged title without consulting mutable metadata.
136
102
  * @param events - live or persisted session log.
@@ -203,6 +169,7 @@ export declare class SessionTitleService extends Service {
203
169
  private supersede;
204
170
  /** Return mutable work state for one session. */
205
171
  private stateFor;
172
+ private titleInputOf;
206
173
  /** Queue detached service work and retain it through service disposal. */
207
174
  private defer;
208
175
  /** Retain one promise until settlement for service and optional provider teardown. */
@@ -5,9 +5,9 @@
5
5
  import { Service } from '@deepseek-ai/cordis';
6
6
  import z from '@deepseek-ai/schemastery';
7
7
  import { z as zod } from 'zod';
8
- import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm';
8
+ import { isAgentLoopRequest } from '@deepseek-ai/dsh-llm';
9
+ import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values';
9
10
  import { fallbackSessionTitle, normalizeSessionTitle } from "./normalize.js";
10
- export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from "./normalize.js";
11
11
  /**
12
12
  * Brand a raw provider id.
13
13
  * @param id - stable non-empty provider identifier supplied by a plugin.
@@ -16,6 +16,7 @@ export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from "
16
16
  export function SessionTitleProviderId(id) {
17
17
  return id;
18
18
  }
19
+ export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from "./normalize.js";
19
20
  /**
20
21
  * Rejection of an explicit user title whose text normalizes to empty — the
21
22
  * one {@link SessionTitleService.rename} failure that blames the input.
@@ -25,30 +26,110 @@ export function SessionTitleProviderId(id) {
25
26
  export class SessionTitleInvalidError extends Error {
26
27
  name = 'SessionTitleInvalidError';
27
28
  }
29
+ /** Extract one eligible human text message from a session event. */
30
+ function sessionTitleUserMessageOf(event) {
31
+ if (event.type !== 'user/message' || event.data.source.kind !== 'user')
32
+ return undefined;
33
+ const content = event.data.content;
34
+ const text = content
35
+ .filter((block) => block.type === 'text')
36
+ .map(block => block.text)
37
+ .join('\n');
38
+ if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0)
39
+ return undefined;
40
+ return { seq: event.seq, text };
41
+ }
42
+ /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
43
+ function copySessionTitleSource(source) {
44
+ switch (source.kind) {
45
+ case 'fallback': return { kind: 'fallback' };
46
+ case 'provider': return {
47
+ kind: 'provider',
48
+ provider: source.provider,
49
+ ...(source.model === undefined ? {} : { model: { ...source.model } }),
50
+ };
51
+ case 'user': return { kind: 'user' };
52
+ /* v8 ignore next -- closed-union exhaustiveness guard */
53
+ default: return assertNever(source, 'SessionTitleSource');
54
+ }
55
+ }
56
+ /** Validate one positive integer configuration field. */
57
+ function assertPositiveInteger(name, value) {
58
+ if (!Number.isInteger(value) || value <= 0) {
59
+ throw new Error(`session-title: ${name} must be a positive integer`);
60
+ }
61
+ }
28
62
  /**
29
- * Collect human text-bearing user messages in log order.
30
- * @param events - session log or persisted replay.
31
- * @param throughSeq - optional inclusive event boundary.
63
+ * Convert title projection state into an immutable snapshot.
64
+ * @param state - the title unit's folded state.
65
+ * @returns the immutable snapshot.
66
+ */
67
+ function titleSnapshotFromState(state) {
68
+ return deepFreeze({
69
+ title: state.title,
70
+ messageSeqs: [...state.messageSeqs],
71
+ source: copySessionTitleSource(state.source),
72
+ eventSeq: state.eventSeq,
73
+ updatedAt: state.updatedAt,
74
+ });
75
+ }
76
+ const EMPTY_TITLE_INPUT = { first: null, count: 0, lastSeq: null };
77
+ const sessionTitleUserMessageSchema = zod.object({
78
+ seq: zod.number().int().nonnegative(),
79
+ text: zod.string(),
80
+ }).strict();
81
+ const titleInputStateSchema = zod.object({
82
+ first: sessionTitleUserMessageSchema.nullable(),
83
+ count: zod.number().int().nonnegative(),
84
+ lastSeq: zod.number().int().nonnegative().nullable(),
85
+ }).strict().superRefine((state, context) => {
86
+ const empty = state.first === null && state.lastSeq === null && state.count === 0;
87
+ const populated = state.first !== null
88
+ && state.lastSeq !== null
89
+ && state.count > 0
90
+ && state.first.seq <= state.lastSeq;
91
+ if (!empty && !populated) {
92
+ context.addIssue({
93
+ code: 'custom',
94
+ message: 'title input state must pair its count with first and last message seqs',
95
+ });
96
+ }
97
+ });
98
+ /**
99
+ * Collect eligible human text messages from a session log, in seq order.
100
+ * The full eligible prefix is only materialized for one provider generation,
101
+ * so it is scanned from the log at execution time rather than retained by
102
+ * the O(1) `titleInput` projection.
103
+ * @param events - the session event log.
104
+ * @param throughSeq - optional inclusive upper seq bound.
32
105
  * @returns eligible messages with exact source seqs.
33
106
  */
34
- export function collectSessionTitleMessages(events, throughSeq) {
107
+ function collectSessionTitleMessages(events, throughSeq) {
35
108
  const messages = [];
36
109
  for (const event of events) {
37
110
  if (throughSeq !== undefined && event.seq > throughSeq)
38
111
  break;
39
- if (event.type !== 'user/message' || event.data.source.kind !== 'user')
40
- continue;
41
- const content = event.data.content;
42
- const text = content
43
- .filter((block) => block.type === 'text')
44
- .map(block => block.text)
45
- .join('\n');
46
- if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0)
47
- continue;
48
- messages.push({ seq: event.seq, text });
112
+ const message = sessionTitleUserMessageOf(event);
113
+ if (message !== undefined)
114
+ messages.push(message);
49
115
  }
50
116
  return messages;
51
117
  }
118
+ const titleViewSchema = zod.string().min(1).nullable();
119
+ /** Latest logged title text and its client view. */
120
+ export const titleProjectionDefinition = {
121
+ key: 'title',
122
+ stateVersion: 1,
123
+ stateSchema: titleViewSchema,
124
+ init: () => null,
125
+ apply: (state, event) => (event.type === 'session/title'
126
+ ? event.data.title
127
+ : state),
128
+ wire: {
129
+ viewSchema: titleViewSchema,
130
+ view: state => state,
131
+ },
132
+ };
52
133
  /**
53
134
  * Fold the latest logged title without consulting mutable metadata.
54
135
  * @param events - live or persisted session log.
@@ -58,37 +139,17 @@ export function foldSessionTitle(events) {
58
139
  const event = events.findLast(item => item.type === 'session/title');
59
140
  if (event === undefined)
60
141
  return undefined;
61
- return deepFreeze({
142
+ return titleSnapshotFromState({
62
143
  title: event.data.title,
63
- messageSeqs: [...event.data.messageSeqs],
64
- source: copySessionTitleSource(event.data.source),
144
+ messageSeqs: event.data.messageSeqs,
145
+ source: event.data.source,
65
146
  eventSeq: event.seq,
66
147
  updatedAt: event.time,
67
148
  });
68
149
  }
69
- /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
70
- function copySessionTitleSource(source) {
71
- switch (source.kind) {
72
- case 'fallback': return { kind: 'fallback' };
73
- case 'provider': return {
74
- kind: 'provider',
75
- provider: source.provider,
76
- ...(source.model === undefined ? {} : { model: { ...source.model } }),
77
- };
78
- case 'user': return { kind: 'user' };
79
- /* v8 ignore next -- closed-union exhaustiveness guard */
80
- default: return assertNever(source, 'SessionTitleSource');
81
- }
82
- }
83
- /** Validate one positive integer configuration field. */
84
- function assertPositiveInteger(name, value) {
85
- if (!Number.isInteger(value) || value <= 0) {
86
- throw new Error(`session-title: ${name} must be a positive integer`);
87
- }
88
- }
89
150
  /** Log-backed title fold plus asynchronous fallback generation. */
90
151
  export class SessionTitleService extends Service {
91
- static inject = ['sessions'];
152
+ static inject = ['sessions', 'sessionProjections'];
92
153
  static Config = z.object({
93
154
  fallbackMaxWords: z.number().step(1).min(1).required(),
94
155
  fallbackMaxBytes: z.number().step(1).min(1).required(),
@@ -127,20 +188,22 @@ export class SessionTitleService extends Service {
127
188
  await this.drain(this.inFlight);
128
189
  this.work.clear();
129
190
  }, 'sessionTitle lifecycle');
130
- // The title projection unit: pure last-wins fold of session/title events
131
- // (the same events foldSessionTitle consumes), serving the plain title
132
- // string clients list rows read. The unit child activates only when a
133
- // projection registry is composed (headless assemblies stay unaffected).
134
- ctx.inject(['sessionProjections'], (projectionCtx) => {
135
- const titleSchema = zod.union([zod.string().min(1), zod.null()]);
136
- projectionCtx.sessionProjections.register({
137
- key: 'title',
138
- stateSchema: titleSchema,
139
- init: () => null,
140
- apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
141
- wire: { viewSchema: titleSchema, view: state => state },
142
- stateVersion: 1,
143
- });
191
+ ctx.sessionProjections.register(titleProjectionDefinition);
192
+ ctx.sessionProjections.register({
193
+ key: 'titleInput',
194
+ stateVersion: 3,
195
+ stateSchema: titleInputStateSchema,
196
+ init: () => EMPTY_TITLE_INPUT,
197
+ apply: (state, event) => {
198
+ const message = sessionTitleUserMessageOf(event);
199
+ if (message === undefined)
200
+ return state;
201
+ return {
202
+ first: state.first ?? message,
203
+ count: state.count + 1,
204
+ lastSeq: message.seq,
205
+ };
206
+ },
144
207
  });
145
208
  ctx.on('session/event', (session, event) => {
146
209
  switch (event.type) {
@@ -221,15 +284,14 @@ export class SessionTitleService extends Service {
221
284
  throw new Error(`session "${session.id}" is not live in this store`);
222
285
  }
223
286
  const registration = this.registration;
224
- const messages = collectSessionTitleMessages(session.events);
225
- const latest = messages.at(-1);
226
- if (registration === undefined || registration.closing || latest === undefined) {
287
+ const input = this.titleInputOf(session);
288
+ if (registration === undefined || registration.closing || input.lastSeq === null) {
227
289
  // Explicit refresh is the unpin even without a provider: a standing
228
290
  // user title must not short-circuit ensureFallback into a no-op, so
229
291
  // re-derive and append the fallback over it when one is derivable.
230
292
  const current = this.get(session);
231
- const [first] = messages;
232
- if (current?.source.kind === 'user' && first !== undefined) {
293
+ const first = input.first;
294
+ if (current?.source.kind === 'user' && first !== null) {
233
295
  this.appendFallback(session, first);
234
296
  signal?.throwIfAborted();
235
297
  return this.get(session);
@@ -243,7 +305,7 @@ export class SessionTitleService extends Service {
243
305
  const work = this.activate({
244
306
  registration,
245
307
  revision,
246
- throughSeq: latest.seq,
308
+ throughSeq: input.lastSeq,
247
309
  }, state, signal);
248
310
  const config = session.requestHeader()?.config;
249
311
  const route = config === undefined ? undefined : { provider: config.provider, model: config.model };
@@ -287,16 +349,16 @@ export class SessionTitleService extends Service {
287
349
  onUserMessage(session, event) {
288
350
  if (!this.serviceActive())
289
351
  return;
290
- if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0)
352
+ if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined)
291
353
  return;
292
354
  // A user rename pins the title: no automatic revision may override it.
293
355
  if (this.get(session)?.source.kind === 'user')
294
356
  return;
295
357
  const registration = this.registration;
296
358
  if (registration !== undefined && !registration.closing) {
297
- const messages = collectSessionTitleMessages(session.events, event.seq);
359
+ const count = this.titleInputOf(session).count;
298
360
  const shouldSchedule = registration.provider.automatic === 'all-prompts'
299
- || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined);
361
+ || (session.header.parentSession === undefined && count === 1 && this.get(session) === undefined);
300
362
  if (shouldSchedule) {
301
363
  const state = this.stateFor(session);
302
364
  const revision = this.supersede(state, 'newer user message superseded title generation');
@@ -337,9 +399,9 @@ export class SessionTitleService extends Service {
337
399
  const pending = state?.pending;
338
400
  if (session === undefined || state === undefined || pending === undefined)
339
401
  return;
340
- const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end');
402
+ const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary;
341
403
  const route = session.requestHeader()?.config;
342
- if (boundary?.type !== 'step/start'
404
+ if (boundary?.kind !== 'start'
343
405
  || boundary.seq <= pending.throughSeq
344
406
  || route?.provider !== options.provider
345
407
  || route.model !== options.model)
@@ -490,6 +552,9 @@ export class SessionTitleService extends Service {
490
552
  }
491
553
  return state;
492
554
  }
555
+ titleInputOf(session) {
556
+ return this.ctx.sessionProjections.stateOf(session, 'titleInput');
557
+ }
493
558
  /** Queue detached service work and retain it through service disposal. */
494
559
  defer(task) {
495
560
  const run = Promise.resolve().then(async () => {
@@ -565,8 +630,8 @@ export class SessionTitleService extends Service {
565
630
  const current = this.get(session);
566
631
  if (current !== undefined)
567
632
  return current;
568
- const [first] = collectSessionTitleMessages(session.events);
569
- if (first === undefined)
633
+ const first = this.titleInputOf(session).first;
634
+ if (first === null)
570
635
  return undefined;
571
636
  const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes);
572
637
  if (title.length === 0)
@@ -2,15 +2,73 @@
2
2
  * Pure types of the title domain: the ONE home of the `title` projection-key
3
3
  * declaration, free of this package's host-side value imports (cordis
4
4
  * service, schemastery, the llm seam). Two namespace projections serve it —
5
- * `./types` for host consumers, `./client/types` (the browser half-entry's
6
- * re-export) for client aggregates — with zero content duplication.
5
+ * `./types` for host consumers and `./client/types` for client aggregates —
6
+ * with zero content duplication.
7
7
  *
8
8
  * @module @deepseek-ai/dsh-session-title/types
9
9
  */
10
10
  export {};
11
+ import type { Branded } from '@deepseek-ai/dsh-brand';
12
+ /** Identifies one session-title provider registration. */
13
+ export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>;
14
+ /** Exact auxiliary model route that produced a title. */
15
+ export interface SessionTitleModelProvenance {
16
+ /** Registered LLM provider route. */
17
+ readonly provider: string;
18
+ /** Provider model id. */
19
+ readonly model: string;
20
+ }
21
+ /** Durable ownership record for an accepted session title. */
22
+ export type SessionTitleSource = {
23
+ readonly kind: 'fallback';
24
+ } | {
25
+ readonly kind: 'provider';
26
+ readonly provider: SessionTitleProviderId;
27
+ readonly model?: SessionTitleModelProvenance;
28
+ } | {
29
+ /** Explicit user rename: pins the title — automatic generation stops scheduling. */
30
+ readonly kind: 'user';
31
+ };
32
+ /** Payload of the log-only `session/title` event. */
33
+ export interface SessionTitleEventData {
34
+ /** Normalized non-empty title text. */
35
+ readonly title: string;
36
+ /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
37
+ readonly messageSeqs: number[];
38
+ /** Whether the built-in fallback, a registered provider, or the user supplied the title. */
39
+ readonly source: SessionTitleSource;
40
+ }
41
+ /** Latest folded title plus the title event's durable envelope facts. */
42
+ export interface SessionTitleSnapshot extends SessionTitleEventData {
43
+ /** Seq of the latest `session/title` event. */
44
+ readonly eventSeq: number;
45
+ /** Timestamp of the latest `session/title` event. */
46
+ readonly updatedAt: number;
47
+ }
48
+ /** Host title projection value. */
49
+ export type TitleProjection = SessionTitleSnapshot;
50
+ /** One eligible human text message exposed to title providers. */
51
+ export interface SessionTitleUserMessage {
52
+ /** Source `user/message` event seq. */
53
+ readonly seq: number;
54
+ /** Exact concatenated text-block content. */
55
+ readonly text: string;
56
+ }
57
+ /** Eligible title input stored as a bounded aggregate. */
58
+ export interface TitleInputState {
59
+ /** The oldest eligible message, or null before any. */
60
+ readonly first: SessionTitleUserMessage | null;
61
+ /** Total eligible messages folded so far. */
62
+ readonly count: number;
63
+ /** Seq of the newest eligible message, or null before any. */
64
+ readonly lastSeq: number | null;
65
+ }
11
66
  declare module '@deepseek-ai/dsh-session-projection/types' {
12
67
  interface SessionProjectionStateMap {
68
+ /** Latest logged title text, or null. */
13
69
  title: string | null;
70
+ /** Eligible human title input. */
71
+ titleInput: TitleInputState;
14
72
  }
15
73
  interface SessionProjectionMap {
16
74
  /**
@@ -2,8 +2,8 @@
2
2
  * Pure types of the title domain: the ONE home of the `title` projection-key
3
3
  * declaration, free of this package's host-side value imports (cordis
4
4
  * service, schemastery, the llm seam). Two namespace projections serve it —
5
- * `./types` for host consumers, `./client/types` (the browser half-entry's
6
- * re-export) for client aggregates — with zero content duplication.
5
+ * `./types` for host consumers and `./client/types` for client aggregates —
6
+ * with zero content duplication.
7
7
  *
8
8
  * @module @deepseek-ai/dsh-session-title/types
9
9
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-session-title",
3
3
  "description": "Log-backed session title service and provider registry for the DeepSeek Harness",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -41,25 +41,29 @@
41
41
  ],
42
42
  "license": "MIT",
43
43
  "peerDependencies": {
44
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
45
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
46
- "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
47
- "@deepseek-ai/cordis": "^4.0.1",
48
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
44
+ "@deepseek-ai/cordis": "^4.0.2",
45
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
46
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.2",
47
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
48
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
50
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.2"
50
51
  },
51
52
  "dependencies": {
52
53
  "zod": "^4.4.3",
53
- "@deepseek-ai/schemastery": "^3.18.1"
54
+ "@deepseek-ai/dsh-util-values": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/schemastery": "^3.18.2"
54
56
  },
55
57
  "devDependencies": {
56
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-session-persistence-sqlite": "^0.1.1-rc.2",
62
- "@deepseek-ai/cordis": "^4.0.1",
63
- "@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2"
58
+ "@deepseek-ai/cordis": "^4.0.2",
59
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-agent-loop": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-session-persistence-sqlite": "^0.1.2-alpha.2"
64
68
  }
65
69
  }