topic-memory 0.1.0

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.
@@ -0,0 +1,251 @@
1
+ # 架构与容量说明
2
+
3
+ [English](./ARCHITECTURE.md)
4
+
5
+ 这是一份补充附录,用来解释 Topic Memory v0.1 的设计逻辑、容量推算和理论边界。正常安装和接入不需要阅读这份文档。
6
+
7
+ ## 1. Topic Memory 真正扩展的是什么
8
+
9
+ Topic Memory **不会扩大 LLM 本身的 context window**。
10
+
11
+ 它做的是把两个经常被混为一谈的东西拆开:
12
+
13
+ - **总共保存了多少对话历史**;
14
+ - **一次模型请求真正需要带上多少历史**。
15
+
16
+ 传统 raw-history 方案通常接近:
17
+
18
+ ```text
19
+ 每次请求的历史 tokens
20
+ ≈ 总 exchange 数 × 每个 exchange 的平均 tokens
21
+ ```
22
+
23
+ Topic Memory 更接近:
24
+
25
+ ```text
26
+ 每次请求的 memory tokens
27
+ ≈ 最近上下文
28
+ + Topic Directory
29
+ + 少量被重新打开的 Topic Packets
30
+ ```
31
+
32
+ 完整 Canonical Transcript 一直保存在存储层,不需要每次回复都重新发送。
33
+
34
+ ## 2. v0.1 的检索成本
35
+
36
+ 定义:
37
+
38
+ - `N` = 已保存的 completed exchanges 总数;
39
+ - `g` = 一个 topic 平均包含多少 exchanges;
40
+ - `d` = 一个 Topic Directory entry 平均占多少 tokens;
41
+ - `k` = 每次重新打开的 topic 数,v0.1 中 `k <= 3`;
42
+ - `t` = 一个 completed exchange 的平均 tokens;
43
+ - `r` = 最近上下文保留的 exchange 数,v0.1 固定为 `5`。
44
+
45
+ Topic 数量大约为:
46
+
47
+ ```text
48
+ T ≈ N / g
49
+ ```
50
+
51
+ Topic Directory 成本大约为:
52
+
53
+ ```text
54
+ DirectoryTokens ≈ T × d
55
+ ≈ (N / g) × d
56
+ ```
57
+
58
+ 最近上下文:
59
+
60
+ ```text
61
+ RecentTokens ≈ r × t
62
+ = 5 × t
63
+ ```
64
+
65
+ 如果一个被重新打开的 topic 平均包含 `g` 个 exchanges,那么最多三个 topic 的原始历史大约为:
66
+
67
+ ```text
68
+ OpenedTopicTokens ≈ k × g × t
69
+ ```
70
+
71
+ 因此 v0.1 一次 memory retrieval 的粗略 prompt 成本可以写成:
72
+
73
+ ```text
74
+ MemoryPromptTokens
75
+ ≈ (N / g) × d
76
+ + 5 × t
77
+ + k × g × t
78
+ + 固定 prompt 开销
79
+ ```
80
+
81
+ 这并不是 O(1) 的无限扩展方案,因为 v0.1 的 Topic Directory 仍然会随着 topic 数量增长。
82
+
83
+ 它真正减少的是:**完整原始 transcript 不再随着 N 一起全部进入每一次请求。**
84
+
85
+ ## 3. “600 → 5,000 exchanges”容量示例
86
+
87
+ 下面只是一个理论容量计算,**不是 benchmark,也不是保证值或硬上限**。
88
+
89
+ 假设:
90
+
91
+ - 模型 context window:`128,000 tokens`;
92
+ - 为 system prompt、当前输入、输出空间和其他上下文预留约 `8,000 tokens`;
93
+ - 可用于历史记录的 prompt budget 约 `120,000 tokens`;
94
+ - 一个 completed exchange(user + assistant)平均 `200 tokens`;
95
+ - 一个 topic 平均包含 `8 exchanges`;
96
+ - 一个 Topic Directory entry 平均约 `60 tokens`;
97
+ - 最近上下文固定 `5 exchanges`;
98
+ - 每次最多重新打开 `3 topics`。
99
+
100
+ ### 完整历史直接塞 prompt
101
+
102
+ ```text
103
+ 120,000 / 200 = 600 exchanges
104
+ ```
105
+
106
+ 在这组假设下,如果每次都把全部历史重新发送,大约 **600 个 completed exchanges** 就已经接近 120k 的历史预算。
107
+
108
+ ### Topic Memory 保存 5,000 exchanges
109
+
110
+ Topic 数量:
111
+
112
+ ```text
113
+ 5,000 / 8 = 625 topics
114
+ ```
115
+
116
+ Topic Directory:
117
+
118
+ ```text
119
+ 625 × 60 = 37,500 tokens
120
+ ```
121
+
122
+ 最近 5 个 exchanges:
123
+
124
+ ```text
125
+ 5 × 200 = 1,000 tokens
126
+ ```
127
+
128
+ 最多打开三个 topic:
129
+
130
+ ```text
131
+ 3 × 8 × 200 = 4,800 tokens
132
+ ```
133
+
134
+ 小计:
135
+
136
+ ```text
137
+ 37,500 + 1,000 + 4,800 = 43,300 tokens
138
+ ```
139
+
140
+ 再给 Selector 指令、时间信息、格式等固定内容预留约 1,000–2,000 tokens,整个 memory 相关输入大约为:
141
+
142
+ ```text
143
+ 44,000–45,000 tokens
144
+ ```
145
+
146
+ 在这个示例中:
147
+
148
+ ```text
149
+ 5,000 / 600 ≈ 8.33×
150
+ ```
151
+
152
+ 因此,可以把它理解成:在同一组假设下,系统能够**索引并按需恢复**的 completed-exchange 历史跨度,从 raw-history 方案约 600 exchanges,提高到示例中的 5,000 exchanges,约 **8.3 倍**。
153
+
154
+ 而如果真的把 5,000 exchanges 全部重新塞进 prompt:
155
+
156
+ ```text
157
+ 5,000 × 200 = 1,000,000 tokens
158
+ ```
159
+
160
+ 相比约一百万 tokens 的完整历史,Topic Memory 这个示例中的一次 memory retrieval 大约只有 44k–45k tokens,理论上减少约 **95%** 的历史 prompt 负载。
161
+
162
+ 再次强调:
163
+
164
+ - 600 不是所有模型的固定极限;
165
+ - 5,000 不是 Topic Memory 的固定最大值;
166
+ - 8.3× 和 95% 都只属于这一组明确假设下的容量推算。
167
+
168
+ 真实结果会受到以下因素影响:
169
+
170
+ - 用户和 assistant 每轮实际长度;
171
+ - tokenizer;
172
+ - 模型 context window;
173
+ - 平均 topic 大小;
174
+ - Topic Worker 的分组质量;
175
+ - Topic Directory entry 的长度;
176
+ - 每次被重新打开的 topic 大小。
177
+
178
+ ## 4. 为什么 5,000 也不是硬上限
179
+
180
+ 存储层本身可以保存超过 5,000 exchanges。
181
+
182
+ v0.1 更现实的扩展瓶颈是 **Topic Directory**。
183
+
184
+ 因为 Memory Selector 当前会读取整个 Topic Directory,所以它的 prompt 成本近似:
185
+
186
+ ```text
187
+ O(topic 数量)
188
+ ```
189
+
190
+ 如果平均每 8 个 exchanges 形成一个 topic,那么 5,000 exchanges 大约产生 625 个 topic entries。
191
+
192
+ 历史继续扩大以后,Directory 自己最终会成为主要开销。
193
+
194
+ 未来版本可以通过以下方式继续扩展:
195
+
196
+ - hierarchical topic directory;
197
+ - coarse-to-fine routing;
198
+ - 服务端 lexical / semantic pre-filter;
199
+ - 按时间分区的 directory;
200
+ - Selector 之前增加 vector / hybrid retrieval。
201
+
202
+ 这些都不属于 v0.1 的范围。
203
+
204
+ ## 5. 为什么不是只保存一份滚动 summary
205
+
206
+ 滚动 summary 很便宜,但持续多次 summary-of-summary 可能会丢失:
207
+
208
+ - 精确措辞;
209
+ - 小细节;
210
+ - 时间顺序;
211
+ - 当时对话的上下文关系。
212
+
213
+ Topic Memory 保留两个不同层次:
214
+
215
+ 1. **Topic metadata**:用于快速定位“过去聊过什么”;
216
+ 2. **Canonical Transcript spans**:用于真正恢复“当时具体说了什么”。
217
+
218
+ 所以 Topic Worker 的目标不是用一段摘要替换原始对话,而是建立一个能重新指回原始证据的索引。
219
+
220
+ 这对于下面这类问题尤其重要:
221
+
222
+ - “我上个月跟你说那个项目的时候到底怎么说的?”
223
+ - “之前我们排除了哪个方案?”
224
+ - “我什么时候跟你提过这件事?”
225
+ - “当时那段对话具体发生了什么?”
226
+
227
+ Selector 先找到 topic,SDK 再打开这个 topic 后面的原始 exchanges。
228
+
229
+ ## 6. 和 long-context memory 研究的关系
230
+
231
+ Topic Memory 的设计动机和 long-context LLM 研究中的一个普遍观察相符:**context window 变大,并不等于模型能稳定、均匀地利用长 prompt 中每一个位置的信息。**
232
+
233
+ 相关研究包括:
234
+
235
+ - **Liu et al., “Lost in the Middle: How Language Models Use Long Contexts”**:研究显示,相关信息处于长上下文不同位置时,模型检索表现可能明显变化。
236
+ - **Maharana et al., “Evaluating Very Long-Term Conversational Memory of LLM Agents” (LoCoMo, ACL 2024)**:使用最长约 600 turns 的长期对话评估模型记忆,并指出长对话中的时间、因果和长期信息理解仍然具有挑战。
237
+ - **Banerjee et al., “APEX-MEM” (ACL 2026)**:研究半结构化、带时间信息的长期对话记忆,以及在 retrieval time 提取紧凑相关历史信息的思路。
238
+
239
+ Topic Memory v0.1 是独立实现,不声称复现上述论文的方法或 benchmark 结果。这里引用这些研究,只是为了说明“长期历史外部保存 + 回复时按需恢复相关证据”这一类设计选择的研究背景。
240
+
241
+ 参考:
242
+
243
+ - https://arxiv.org/abs/2307.03172
244
+ - https://aclanthology.org/2024.acl-long.747/
245
+ - https://aclanthology.org/2026.acl-long.749/
246
+
247
+ ## 7. 最准确的一句话描述
248
+
249
+ > Topic Memory 把不断增长的原始聊天记录,变成“持久化 transcript + 轻量 topic 索引”;每次新请求只定位并重新打开少量相关 transcript spans,而不是重新发送全部历史。
250
+
251
+ 它的价值不是承诺一个固定的“最多能记住多少轮”,而是让**总历史规模**不再和**每一次模型调用的 prompt 大小**近似一比一增长。
package/docs/USAGE.md ADDED
@@ -0,0 +1,327 @@
1
+ # Integration Guide
2
+
3
+ [简体中文](./USAGE.zh-CN.md) · [Architecture & capacity notes](./ARCHITECTURE.md)
4
+
5
+ This is the practical guide for wiring Topic Memory into an existing chat app or agent.
6
+
7
+ The integration model is deliberately narrow:
8
+
9
+ > **Your app already knows how to call a Main LLM. Topic Memory runs beside that call, restores relevant older context, and hands the result back to you.**
10
+
11
+ You do not need to rewrite your chat stack around the SDK.
12
+
13
+ ## 1. Know the three roles
14
+
15
+ ### Topic Worker
16
+
17
+ A background memory-organizing job. It groups completed exchanges into topic instances and stores topic metadata plus exact transcript spans.
18
+
19
+ ### Memory Selector
20
+
21
+ A retrieval job. Before a new reply, it looks at the current message, the latest five completed exchanges, and the Topic Directory. It may select up to three older topics to reopen.
22
+
23
+ ### Your Main LLM
24
+
25
+ The model that writes the actual user-facing reply.
26
+
27
+ In the default v0.1 setup, **one Memory LLM handles both Topic Worker and Memory Selector**:
28
+
29
+ ```ts
30
+ createMemory({ storage, llm: memoryLlm })
31
+ ```
32
+
33
+ That Memory LLM is not your Main LLM. The SDK never calls your Main LLM for you.
34
+
35
+ ## 2. Create the memory engine
36
+
37
+ ```ts
38
+ import {
39
+ createMemory,
40
+ createOpenAICompatibleMemoryLlm,
41
+ InMemoryStorage,
42
+ } from 'topic-memory';
43
+
44
+ const memoryLlm = createOpenAICompatibleMemoryLlm({
45
+ baseUrl: process.env.MEMORY_LLM_BASE_URL!,
46
+ apiKey: process.env.MEMORY_LLM_API_KEY,
47
+ model: process.env.MEMORY_LLM_MODEL!,
48
+ });
49
+
50
+ const memory = createMemory({
51
+ storage: new InMemoryStorage(),
52
+ llm: memoryLlm,
53
+ });
54
+ ```
55
+
56
+ `InMemoryStorage` is good for tests and demos. It resets when the process exits.
57
+
58
+ For browser persistence, use `IndexedDbMemoryStorage`. For a production backend, implement the exported `MemoryStorage` interface and connect your own database.
59
+
60
+ ## 3. Wrap one normal chat turn
61
+
62
+ The required order is:
63
+
64
+ ```text
65
+ User sends message
66
+
67
+
68
+ memory.begin()
69
+
70
+
71
+ memory.retrieve()
72
+
73
+
74
+ YOUR Main LLM
75
+
76
+
77
+ memory.completeExchange()
78
+
79
+
80
+ memory.maybeRunTopicWorker()
81
+ ```
82
+
83
+ A complete example:
84
+
85
+ ```ts
86
+ async function handleUserMessage(userMessage: string) {
87
+ const pending = await memory.begin(userMessage);
88
+
89
+ try {
90
+ const retrieved = await memory.retrieve({ userMessage });
91
+
92
+ const assistantReply = await myOwnMainLlm({
93
+ userMessage,
94
+ memoryContext: retrieved.memoryContext,
95
+ recentContext: retrieved.recentContext,
96
+ });
97
+
98
+ await memory.completeExchange({
99
+ exchangeId: pending.id,
100
+ assistantText: assistantReply,
101
+ });
102
+
103
+ await memory.maybeRunTopicWorker();
104
+
105
+ return assistantReply;
106
+ } catch (error) {
107
+ await memory.failExchange({
108
+ exchangeId: pending.id,
109
+ failureReason: error instanceof Error ? error.message : String(error),
110
+ });
111
+
112
+ throw error;
113
+ }
114
+ }
115
+ ```
116
+
117
+ `myOwnMainLlm()` is a placeholder for the model call your application already has. Topic Memory never calls it internally.
118
+
119
+ ## 4. Put memory into your Main LLM prompt
120
+
121
+ `retrieve()` gives you two useful layers:
122
+
123
+ - `recentContext` — latest five completed exchanges;
124
+ - `memoryContext` — older topic memory restored only when relevant.
125
+
126
+ ```ts
127
+ const retrieved = await memory.retrieve({ userMessage });
128
+ ```
129
+
130
+ The full result includes:
131
+
132
+ ```ts
133
+ {
134
+ recentContext,
135
+ topicDirectory,
136
+ selectedTopicIds,
137
+ openedTopicPackets,
138
+ memoryContext,
139
+ needsTimeMetadata,
140
+ trace,
141
+ }
142
+ ```
143
+
144
+ A common integration pattern is:
145
+
146
+ ```ts
147
+ const assistantReply = await myOwnMainLlm({
148
+ messages: [
149
+ {
150
+ role: 'system',
151
+ content: [
152
+ baseSystemPrompt,
153
+ retrieved.memoryContext,
154
+ ].filter(Boolean).join('\n\n'),
155
+ },
156
+ {
157
+ role: 'user',
158
+ content: userMessage,
159
+ },
160
+ ],
161
+ });
162
+ ```
163
+
164
+ An empty `memoryContext` is valid. It means there is no relevant older topic, no topic exists yet, or retrieval safely degraded after a selector failure.
165
+
166
+ ## 5. What happens behind the API
167
+
168
+ ### `memory.begin(userMessage)`
169
+
170
+ Creates a pending Canonical Exchange before the Main LLM call starts.
171
+
172
+ ### `memory.retrieve({ userMessage })`
173
+
174
+ Builds the latest five-exchange recent context, exposes the Topic Directory to the Memory Selector, reopens selected historical topic spans, and returns `memoryContext`.
175
+
176
+ ### Your Main LLM
177
+
178
+ Receives current input plus whatever memory fields you choose to inject.
179
+
180
+ ### `memory.completeExchange(...)`
181
+
182
+ Marks the turn as completed and stores the final assistant reply as canonical history.
183
+
184
+ ### `memory.maybeRunTopicWorker()`
185
+
186
+ Asks the SDK whether enough completed active-tail history exists to reorganize topics. You can call it after every successful turn; the SDK enforces its own gate.
187
+
188
+ ## 6. The first six completed exchanges
189
+
190
+ Topic Worker does not run before at least six completed exchanges exist.
191
+
192
+ Before that point:
193
+
194
+ - Canonical Transcript is still recorded;
195
+ - `recentContext` still works;
196
+ - `memoryContext` may be empty because the long-term Topic Store has not been created yet.
197
+
198
+ This is normal startup behavior.
199
+
200
+ ## 7. If the Main LLM fails
201
+
202
+ If `begin()` succeeded but your Main LLM request fails, do not leave the exchange pending forever.
203
+
204
+ ```ts
205
+ await memory.failExchange({
206
+ exchangeId: pending.id,
207
+ failureReason: 'provider_timeout',
208
+ });
209
+ ```
210
+
211
+ Failed exchanges remain part of the canonical lifecycle but are not treated as completed conversational evidence by Topic Worker.
212
+
213
+ ## 8. One Memory LLM or two
214
+
215
+ Most apps can use one Memory LLM for both memory jobs:
216
+
217
+ ```ts
218
+ const memory = createMemory({
219
+ storage,
220
+ llm: memoryLlm,
221
+ });
222
+ ```
223
+
224
+ For advanced deployments, split the roles:
225
+
226
+ ```ts
227
+ const memory = createMemory({
228
+ storage,
229
+ topicWorker: topicWorkerLlm,
230
+ selector: selectorLlm,
231
+ });
232
+ ```
233
+
234
+ Both implement the exported `MemoryLlm` interface.
235
+
236
+ The split can be useful if, for example, you want a stronger model for topic organization and a cheaper low-latency model for selection.
237
+
238
+ Neither configuration changes ownership of the host Main LLM.
239
+
240
+ ## 9. Storage choices
241
+
242
+ ### In-memory
243
+
244
+ ```ts
245
+ new InMemoryStorage()
246
+ ```
247
+
248
+ Use for local demos and tests. Data disappears when the process exits.
249
+
250
+ ### Browser IndexedDB
251
+
252
+ ```ts
253
+ new IndexedDbMemoryStorage()
254
+ ```
255
+
256
+ Use in environments with IndexedDB support.
257
+
258
+ ### Your own backend database
259
+
260
+ Implement `MemoryStorage` to connect PostgreSQL, SQLite, Redis, a KV store, or another persistence layer.
261
+
262
+ For real multi-user products, create or scope one memory store per conversation / user / agent identity according to your own tenancy model.
263
+
264
+ ## 10. Inspect and debug memory
265
+
266
+ ```ts
267
+ const exchanges = await memory.listExchanges();
268
+ const topics = await memory.listTopics();
269
+ const latestWorkerRun = await memory.getLatestTopicWorkerRun();
270
+ ```
271
+
272
+ These methods are useful for internal admin tools, debugging, and understanding why a topic was or was not retrieved.
273
+
274
+ `retrieve().trace` also exposes selector diagnostics.
275
+
276
+ ## 11. Failure behavior
277
+
278
+ The memory layer is designed to fail soft instead of taking down the host chat path.
279
+
280
+ - **Topic Worker provider failure:** recorded; existing topics remain.
281
+ - **Topic Worker invalid JSON / validation rejection:** rejected; invalid topics are not persisted.
282
+ - **Memory Selector failure:** long-term `memoryContext` becomes empty.
283
+ - **No relevant older topic:** `memoryContext` is empty by design.
284
+
285
+ Your host application decides whether to log, retry, alert, or simply continue without long-term memory.
286
+
287
+ ## 12. Recommended production layout
288
+
289
+ ```text
290
+ Client
291
+
292
+
293
+ Your backend
294
+ ├── Topic Memory SDK
295
+ │ ├── Memory Storage
296
+ │ └── Memory LLM
297
+ │ ├── Topic Worker role
298
+ │ └── Memory Selector role
299
+
300
+ └── Your Main LLM
301
+ └── user-facing reply
302
+ ```
303
+
304
+ Keep paid provider secrets on a trusted backend or proxy.
305
+
306
+ ## 13. Scaling expectations
307
+
308
+ Topic Memory does not enlarge a model context window. It reduces the need to replay all historical text on every request.
309
+
310
+ The v0.1 architecture stores the full Canonical Transcript externally, keeps a lightweight Topic Directory, and reopens at most three historical topics per retrieval.
311
+
312
+ For a worked example showing how a raw-history ~600-exchange prompt can correspond to a selectively retrievable ~5,000-exchange archive under explicit assumptions, see [Architecture & capacity notes](./ARCHITECTURE.md).
313
+
314
+ That example is a theoretical capacity calculation, not a hard product limit or benchmark claim.
315
+
316
+ ## 14. Validate the package
317
+
318
+ ```bash
319
+ npm install
320
+ npm run build
321
+ npm run typecheck
322
+ npm test
323
+ npm pack --dry-run
324
+ npm run smoke:consumer
325
+ ```
326
+
327
+ `smoke:consumer` packs the SDK, installs the tarball into a fresh temporary Node project, imports only public package exports, runs the memory pipeline, and verifies that a simulated host-owned Main LLM receives a non-empty `memoryContext`.