Skip to content

Commit 61a2a6a

Browse files
authored
Merge pull request #13 from minorcell/dev
feat: optimized token counting, added memory capability
2 parents 9c109bc + 3479dc4 commit 61a2a6a

22 files changed

Lines changed: 613 additions & 89 deletions

‎docs/config-storage.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
sessions/
1111
25/12/01/UUID.jsonl # 按 YY/MM/DD 分桶的会话日志(JSONL 事件)
1212
25/12/02/UUID.jsonl
13+
memory.md # 长期记忆(由 memory 工具追加,随系统提示词注入)
1314
```
1415

1516
- 路径可通过环境变量 `MEMO_HOME` 覆盖,默认为 `~/.memo`。
@@ -22,6 +23,8 @@
2223
current_provider = "deepseek"
2324
# 每个 trun 最大的步骤数
2425
max_steps = 100
26+
# 是否启用流式输出(默认 true)
27+
stream_output = true
2528

2629
[[providers]]
2730
name = "deepseek"
@@ -44,6 +47,8 @@ base_url = "https://api.openai.com/v1"
4447
- `env_api_key`: 读取的环境变量名称(不在文件中存密钥)。
4548
- `model`: 默认模型名。
4649
- `base_url`: 可选,兼容 OpenAI 生态;缺省则使用 SDK 默认。
50+
- `stream_output`: 是否默认使用 LLM 流式输出(stream=true)。
51+
- `memory.md`: 独立文件存储长期记忆,由 `memory` 工具追加,Session 启动时附加到系统提示词。
4752

4853
## 核心改造要点
4954

‎docs/core.md‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@
66

77
- `config/`:配置与路径
88
- `config.ts`:读取/写入 `~/.memo/config.toml`,provider 选择(name/env_api_key/model/base_url)、会话路径 `sessions/YY/MM/DD/<uuid>.jsonl`、sessionId 生成。
9-
- `constants.ts`:兜底默认(如 MAX_STEPS)。
109
- `runtime/`:运行时与日志
1110
- `prompt.md/prompt.ts`:系统提示词加载(内容为 JSON 协议说明)。
1211
- `history.ts`:JSONL sink 与事件构造。
1312
- `defaults.ts`:补全工具集、LLM、prompt、history sink、tokenizer、maxSteps(基于配置)。
1413
- `session.ts`:Session/Turn 状态机,执行 ReAct 循环、写事件、统计 token、触发回调。
15-
- `llm/`:模型与 tokenizer
16-
- `openai.ts`:OpenAI SDK 适配,`createOpenAIClient(provider)` 基于配置生成客户端,兼容 DeepSeek/OpenAI。
17-
- `tokenizer.ts`:tiktoken 封装。
18-
- `utils/`:解析工具(assistant 输出解析、消息包装)。
14+
- `utils/`:解析工具(assistant 输出解析、消息包装)与 tokenizer 封装(tiktoken)。
1915
- `types.ts`:公共类型。
2016
- `index.ts`:包入口,导出上述模块。
2117

@@ -55,10 +51,10 @@ await session.close()
5551
- 默认写入 `~/.memo/sessions/YY/MM/DD/<uuid>.jsonl`;包含 provider、模型、tokenizer、token 用量等元数据。
5652
- 仅写 JSONL 事件,默认写入 `~/.memo/sessions/YY/MM/DD/<uuid>.jsonl`。
5753

58-
## LLM 适配(llm/openai.ts)
54+
## LLM 适配(runtime/defaults.ts)
5955

60-
- `createOpenAIClient(provider)` 基于 provider 配置(env_api_key/model/base_url)构建 OpenAI SDK 客户端。
61-
- 环境变量只存 API key,不写入配置;可用 `OPENAI_BASE_URL/OPENAI_MODEL` 临时覆盖。
56+
- 通过 `withDefaultDeps` 内置基于 OpenAI SDK 的调用(按配置选择 provider、model、base_url、env_api_key)。
57+
- 优先使用传入的 `callLLM`,否则读取环境变量(当前 provider 的 env_api_key/OPENAI_API_KEY/DEEPSEEK_API_KEY)。
6258

6359
## 工具协议与注册表
6460

‎docs/memory.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Memory(长期记忆)设计
2+
3+
## 目标
4+
5+
- 为 memo-cli 提供跨 Session 的长期记忆,存储在用户目录(`~/.memo/memory.md`)。
6+
- 在每次 Session 启动时,将 memory 内容随系统提示词注入,让模型可利用用户画像/偏好。
7+
- 提供 `memory` 工具,允许模型在给出最终回答前写入一条极短的摘要。
8+
9+
## 存储
10+
11+
- 路径:`MEMO_HOME` 下的 `memory.md`(默认 `~/.memo/memory.md`)。
12+
- 结构:追加式 Markdown 列表(`- <ISO 时间> <摘要>`)。
13+
- 创建:不存在时自动创建所在目录。
14+
15+
## 注入时机
16+
17+
- `createAgentSession` 启动时读取 `memory.md`,非空则在系统提示词末尾附加「长期记忆」段落。
18+
- memory 内容不可在回答中直接泄露,只作为参考。
19+
20+
## memory 工具
21+
22+
- 名称:`memory`
23+
- 输入:`{"note":"一句极短摘要,≤32 字符"}`,自动清理多余空白/换行并追加到 `memory.md`。
24+
- 使用时机:仅在准备返回 final 之前,如发现可复用的用户画像/习惯/偏好信息可调用一次,避免频繁调用。
25+
- 输出:确认写入的文件路径,失败时返回错误信息。
26+
27+
## 使用建议
28+
29+
- 摘要要短、泛化,避免敏感信息与原文(例:`用户常用中文回答,偏好简洁步骤`)。
30+
- 可后续迭代:重复检测/压缩、清理/导出命令、权限控制(600)。
31+
32+
## 测试覆盖
33+
34+
- memory 工具:输入校验、写入与内容清理。
35+
- 注入:存在 `memory.md` 时系统提示词包含记忆内容。

‎docs/token-counting.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# memo-cli 的 Token 计数策略
2+
3+
本文说明 memo-cli 如何估算与记录 token,用于提示词预算、超限防护和 usage 对账。
4+
5+
## 计数实现
6+
7+
- **底层编码器**:使用 `@dqbd/tiktoken`,默认 encoding `cl100k_base`;可通过 `tokenizerModel` 覆盖。
8+
- **纯文本计数**:`countText(text)` 直接对字符串编码,获得长度。
9+
- **消息数组计数(ChatML 近似)**:`countMessages(messages)` 采用 OpenAI ChatML 常用估算:
10+
- 每条消息固定开销 4 tokens(role/name 封装等)。
11+
- `content` 本身按 tiktoken 编码计数。
12+
- 如果后续支持 `name` 字段,则额外加 1 token。
13+
- 末尾补充 2 tokens 用于 assistant priming。
14+
15+
该策略比单纯串联文本更贴近实际 ChatML 开销,但仍是近似值。
16+
17+
## 使用场景
18+
19+
- **提示词预算**:`runTurn` 会在每步前用 `countMessages` 估算 prompt tokens,触发:
20+
- `warnPromptTokens`:打印警告。
21+
- `maxPromptTokens`:超过时立即返回提示,避免向 LLM 发送超限请求。
22+
- **usage 对账**:每步会结合本地计数与模型返回的 `usage`(若提供),记录到 tokenUsage 与 JSONL 历史事件。
23+
24+
## 精度与局限
25+
26+
- ChatML 不同模型的固定开销略有差异,当前采用「每消息 4 + 末尾 2」的通用估算,可能与特定模型存在 ±几十 token 的偏差。
27+
- 未显式处理工具调用/函数调用的额外结构开销,如需精确对账可在未来针对具体模型定制 constants。
28+
- 若使用自定义 `callLLM`,建议传入对应模型的 encoding 名称或自定义 `tokenCounter` 以匹配真实开销。
29+
30+
## 如何覆盖
31+
32+
- 创建 Session 时传入 `tokenizerModel` 或直接注入自定义 `tokenCounter`:
33+
```ts
34+
import { createTokenCounter, createAgentSession } from '@memo/core'
35+
36+
const tokenCounter = createTokenCounter('gpt-4o-mini')
37+
const session = await createAgentSession({ tokenCounter }, { warnPromptTokens: 8_000 })
38+
```
39+
- 自定义 counter 只需实现 `countText`、`countMessages`、`dispose` 接口。

‎packages/core/src/config/config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@ export type ProviderConfig = {
1515
export type MemoConfig = {
1616
current_provider: string
1717
max_steps?: number
18+
stream_output?: boolean
1819
providers: ProviderConfig[]
1920
}
2021

2122
const DEFAULT_MEMO_HOME = join(homedir(), '.memo')
2223
const DEFAULT_SESSIONS_DIR = 'sessions'
24+
const DEFAULT_MEMORY_FILE = 'memory.md'
2325

2426
const DEFAULT_CONFIG: MemoConfig = {
2527
current_provider: 'deepseek',
2628
max_steps: 100,
29+
stream_output: true,
2730
providers: [
2831
{
2932
name: 'deepseek',
@@ -55,6 +58,7 @@ ${p.base_url ? `base_url = "${p.base_url}"\n` : ''}`,
5558
return `# Memo config. Edit to change provider/model/base_url.
5659
current_provider = "${config.current_provider}"
5760
max_steps = ${config.max_steps ?? 100}
61+
stream_output = ${config.stream_output ?? true}
5862
5963
${providers}`.trim()
6064
}
@@ -84,6 +88,7 @@ export async function loadMemoConfig(): Promise<LoadedConfig> {
8488
const merged: MemoConfig = {
8589
current_provider: parsed.current_provider ?? DEFAULT_CONFIG.current_provider,
8690
max_steps: parsed.max_steps ?? DEFAULT_CONFIG.max_steps,
91+
stream_output: parsed.stream_output ?? DEFAULT_CONFIG.stream_output,
8792
providers: parsed.providers ?? [],
8893
}
8994
const needsSetup = !merged.providers.length
@@ -105,6 +110,10 @@ export function getSessionsDir(loaded: LoadedConfig, options: AgentSessionOption
105110
return expandHome(base)
106111
}
107112

113+
export function getMemoryPath(loaded: LoadedConfig) {
114+
return join(loaded.home, DEFAULT_MEMORY_FILE)
115+
}
116+
108117
export function buildSessionPath(baseDir: string, sessionId: string) {
109118
const now = new Date()
110119
const yy = String(now.getFullYear()).slice(2)

‎packages/core/src/runtime/defaults.ts‎

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createTokenCounter } from '@memo/core/utils/tokenizer'
44
import {
55
buildSessionPath,
66
getSessionsDir,
7+
getMemoryPath,
78
loadMemoConfig,
89
selectProvider,
910
} from '@memo/core/config/config'
@@ -37,12 +38,28 @@ export async function withDefaultDeps(
3738
const loaded = await loadMemoConfig()
3839
const config = loaded.config
3940
const tools = deps.tools ?? TOOLKIT
40-
const loadPrompt = deps.loadPrompt ?? defaultLoadPrompt
41+
const loadPrompt = async () => {
42+
const basePrompt = await (deps.loadPrompt ?? defaultLoadPrompt)()
43+
const memoryPath = getMemoryPath(loaded)
44+
try {
45+
const file = Bun.file(memoryPath)
46+
if (await file.exists()) {
47+
const memory = (await file.text()).trim()
48+
if (memory) {
49+
return `${basePrompt}\n\n# 长期记忆\n${memory}`
50+
}
51+
}
52+
} catch (err) {
53+
console.warn(`读取 memory 失败: ${(err as Error).message}`)
54+
}
55+
return basePrompt
56+
}
57+
const streamOutput = options.stream ?? config.stream_output ?? true
4158
return {
4259
tools,
4360
callLLM:
4461
deps.callLLM ??
45-
(async (messages) => {
62+
(async (messages, onChunk) => {
4663
const provider = selectProvider(config, options.providerName)
4764
const apiKey =
4865
process.env[provider.env_api_key] ??
@@ -57,22 +74,40 @@ export async function withDefaultDeps(
5774
apiKey,
5875
baseURL: provider.base_url,
5976
})
60-
const data = await client.chat.completions.create({
61-
model: provider.model,
62-
messages,
63-
temperature: 0.35,
64-
})
65-
const content = data.choices?.[0]?.message?.content
66-
if (typeof content !== 'string') {
67-
throw new Error('OpenAI 兼容接口返回内容为空')
68-
}
69-
return {
70-
content,
71-
usage: {
72-
prompt: data.usage?.prompt_tokens ?? undefined,
73-
completion: data.usage?.completion_tokens ?? undefined,
74-
total: data.usage?.total_tokens ?? undefined,
75-
},
77+
if (streamOutput) {
78+
const stream = await client.chat.completions.create({
79+
model: provider.model,
80+
messages,
81+
temperature: 0.35,
82+
stream: true,
83+
})
84+
let content = ''
85+
for await (const part of stream) {
86+
const delta = part.choices?.[0]?.delta?.content
87+
if (delta) {
88+
content += delta
89+
onChunk?.(delta)
90+
}
91+
}
92+
return { content, streamed: true }
93+
} else {
94+
const data = await client.chat.completions.create({
95+
model: provider.model,
96+
messages,
97+
temperature: 0.35,
98+
})
99+
const content = data.choices?.[0]?.message?.content
100+
if (typeof content !== 'string') {
101+
throw new Error('OpenAI 兼容接口返回内容为空')
102+
}
103+
return {
104+
content,
105+
usage: {
106+
prompt: data.usage?.prompt_tokens ?? undefined,
107+
completion: data.usage?.completion_tokens ?? undefined,
108+
total: data.usage?.total_tokens ?? undefined,
109+
},
110+
}
76111
}
77112
}),
78113
loadPrompt,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import assert from 'node:assert'
2+
import { join } from 'node:path'
3+
import { tmpdir } from 'node:os'
4+
import { describe, test, beforeAll, afterAll } from 'bun:test'
5+
import { $ } from 'bun'
6+
import { createAgentSession, createTokenCounter } from '@memo/core'
7+
8+
let tempHome: string
9+
let prevMemoHome: string | undefined
10+
11+
async function makeTempDir(prefix: string) {
12+
const dir = join(tmpdir(), `${prefix}-${crypto.randomUUID()}`)
13+
await $`mkdir -p ${dir}`
14+
return dir
15+
}
16+
17+
async function removeDir(dir: string) {
18+
await $`rm -rf ${dir}`
19+
}
20+
21+
beforeAll(async () => {
22+
tempHome = await makeTempDir('memo-core-memory')
23+
prevMemoHome = process.env.MEMO_HOME
24+
process.env.MEMO_HOME = tempHome
25+
})
26+
27+
afterAll(async () => {
28+
if (prevMemoHome === undefined) {
29+
delete process.env.MEMO_HOME
30+
} else {
31+
process.env.MEMO_HOME = prevMemoHome
32+
}
33+
await removeDir(tempHome)
34+
})
35+
36+
describe('memory injection', () => {
37+
test('loads memory into system prompt when file exists', async () => {
38+
const memoryPath = join(tempHome, 'memory.md')
39+
await Bun.write(memoryPath, '用户偏好:中文回答\n')
40+
41+
const session = await createAgentSession(
42+
{
43+
callLLM: async () => ({ content: JSON.stringify({ final: 'ok' }) }),
44+
historySinks: [],
45+
tokenCounter: createTokenCounter('cl100k_base'),
46+
},
47+
{ mode: 'once' },
48+
)
49+
try {
50+
const systemPrompt = session.history[0]?.content ?? ''
51+
assert.ok(systemPrompt.includes('长期记忆'), 'system prompt should include memory section')
52+
assert.ok(systemPrompt.includes('用户偏好:中文回答'), 'memory content should be injected')
53+
} finally {
54+
await session.close()
55+
}
56+
})
57+
})

‎packages/core/src/runtime/prompt.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- `glob`: `{"pattern":"**/*.ts","path":"/repo"}`
1414
- `grep`: `{"pattern":"TODO","path":"/repo","output_mode":"content|files_with_matches|count","glob":"src/**/*.ts","-i":false,"-A":1,"-B":1}`
1515
- `fetch`: `{"url":"https://..."}`
16+
- `memory`: `{"note":"一句极短的用户画像/偏好/身份总结,≤32字符"}`(仅在准备返回 final 前追加)
1617

1718
# 允许的输出格式
1819

@@ -35,3 +36,4 @@
3536
- 每次仅调用一个工具。
3637
- 收到 observation 后,如已足够回答,直接返回 final。
3738
- 不要输出 XML/Markdown/额外文字;输出必须是 JSON 对象字符串。
39+
- 如发现可复用的用户画像/习惯/偏好等信息,且准备给出 final,可先调用一次 `memory` 工具写入摘要,避免重复调用;记忆内容不得包含敏感/原文,仅保留极短摘要。

0 commit comments

Comments
 (0)