# Trajectory: 家庭记账 · session 1(长出带持久存储的记账工具)
真实 E2E 会话记录(`dsh web` RPC 驱动,DeepSeek-V4-Flash,未经剪辑;从 session.history 事件流提取)。
**用户输入:**
> 帮我记个账:今天买菜花了 86,昨天加油 400。以后我随口说一句你就得记下来,月底我会问这个月总共花了多少、吃饭花了多少这种,要能分类统计
我来帮你建一个持久的记账能力:以后你随口说花钱的事,我就自动记下来,月底随时可以按分类统计。
先看下当前环境和已有的工具:
### step 1 · 🔧 `bash` {"command": "date +%Y-%m-%d && date -v-1d +%Y-%m-%d", "description": "Get today's and yesterday's dates"}
```
2026-08-06
2026-08-05
```
### step 1 · 🔧 `evolve_list` {}
```
Evolutions:
- currency-converter (rev 1, active): 实时汇率换算工具,支持 USD/CNY/EUR/JPY 等 150+ 币种,跨境采购报价换算直接用
- error-code-lookup (rev 1, active): 实时查询 demo-shop 错误码表(docs/error-codes.md),支持错误码或关键词检索,避免每次手动翻表。
- family-reminders (rev 1, active): 家庭重要日期常驻工具:宝宝年龄(2026-03-18 出生)、结婚纪念日倒计时(5-20)、下次是谁的生日(宝宝 03-18 / 妈妈 11-02)。用户随口问"宝宝多大了/离纪念日几天/快到谁生日"时调用。
- weather-check (rev 1, active): 查任意城市天气(当前+未来1-7天,含降水概率与带伞建议),支持中文城市名和家庭成员别名(爸妈→成都、妹妹→深圳),满足"帮家里人看天气"的反复需求。
```
今天是 2026-08-06。现在创建记账工具(自动分类:买菜→餐饮、加油→交通,数据持久化保存):
### step 2 · 🔧 `evolve_add` {"name": "expense-tracker", "description": "个人记账:用户随口提到花钱(买菜/加油/吃饭/打车等)就用 expense_add 记录,自动按描述分类(买菜/吃饭→餐饮,加油/打车→交通);用户问\"这个月花了多少/吃饭花了多少\"时用 expense_stats 按月分类统计。数据持久化在账单文件里。"}
模型现场编写的插件源码(source 参数)
```js
import { defineTool } from '@deepseek-ai/dsh-tools'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
export const name = 'expense-tracker'
export const inject = ['tools']
const DATA_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'expense-ledger.json')
const CATEGORIES = ['餐饮', '交通', '购物', '居住', '娱乐', '医疗', '教育', '其他']
function loadLedger() {
try {
const raw = fs.readFileSync(DATA_FILE, 'utf8')
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
function saveLedger(records) {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true })
fs.writeFileSync(DATA_FILE, JSON.stringify(records, null, 2))
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function currentMonth() {
return todayStr().slice(0, 7)
}
function inferCategory(desc) {
if (/菜|肉|蛋|奶|米|面|水果|零食|超市|买菜|吃饭|外卖|奶茶|咖啡|早餐|午餐|晚餐|火锅|烧烤|餐厅|食材|海鲜/.test(desc)) return '餐饮'
if (/油|加油|停车|地铁|打车|出租|公交|高铁|火车|机票|飞机|高速|充电|单车/.test(desc)) return '交通'
if (/衣|裤|鞋|包|淘宝|京东|拼多多|网购|护肤|化妆|家电|数码|手机/.test(desc)) return '购物'
if (/房租|租金|水电|燃气|物业|宽带|话费|网费/.test(desc)) return '居住'
if (/电影|游戏|旅游|门票|KTV|健身|会员|视频/.test(desc)) return '娱乐'
if (/药|医院|看病|体检|挂号|牙/.test(desc)) return '医疗'
if (/书|学费|课程|培训|考试/.test(desc)) return '教育'
return '其他'
}
const money = (n) => `¥${Number(n).toFixed(2)}`
export function apply(ctx) {
ctx.tools.register(defineTool({
name: 'expense_add',
description: '记一笔账。用户随口提到花了多少钱(买菜、加油、吃饭、打车等)时调用;自动按描述推断分类,也可指定分类和日期。',
parameters: {
amount: { type: 'number', required: true, description: '金额(人民币元)' },
description: { type: 'string', required: true, description: '花在什么上,如 买菜、加油' },
category: { type: 'string', description: '分类:餐饮/交通/购物/居住/娱乐/医疗/教育/其他,不传则自动推断' },
date: { type: 'string', description: '日期 YYYY-MM-DD,默认今天' },
},
output: {
schema: {
type: 'object',
properties: {
id: { type: 'number' },
date: { type: 'string' },
amount: { type: 'number' },
category: { type: 'string' },
description: { type: 'string' },
},
},
render: (_args, v) => [{ type: 'text', text: `已记账:${v.date} ${v.description} ${money(v.amount)}(${v.category})` }],
},
async execute(args) {
const amount = Number(args.amount)
if (!Number.isFinite(amount) || amount <= 0) throw new Error('金额必须是大于 0 的数字')
const date = /^\d{4}-\d{2}-\d{2}$/.test(args.date || '') ? args.date : todayStr()
const category = args.category && CATEGORIES.includes(args.category) ? args.category : inferCategory(args.description)
const records = loadLedger()
const id = records.length ? Math.max(...records.map(r => r.id)) + 1 : 1
const record = { id, date, amount: Math.round(amount * 100) / 100, category, description: args.description }
records.push(record)
saveLedger(records)
return record
},
}))
ctx.tools.register(defineTool({
name: 'expense_stats',
description: '按月/分类统计支出:总金额、笔数、各分类小计。用户问 这个月花了多少 / 吃饭花了多少 这类问题时调用。',
parameters: {
month: { type: 'string', description: '月份 YYYY-MM,默认当月' },
category: { type: 'string', description: '只看某个分类,如 餐饮' },
},
output: {
schema: {
type: 'object',
properties: {
month: { type: 'string' },
total: { type: 'number' },
count: { type: 'number' },
breakdown: { type: 'array', items: { type: 'object' } },
},
},
render: (_args, v) => {
const lines = [`📊 ${v.month} 支出合计:${money(v.total)}(共 ${v.count} 笔)`]
for (const b of v.breakdown) {
lines.push(` ${b.category}:${money(b.amount)}(${b.count} 笔)`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
async execute(args) {
const month = /^\d{4}-\d{2}$/.test(args.month || '') ? args.month : currentMonth()
const records = loadLedger().filter(r => r.date.startsWith(month))
const filtered = args.category ? records.filter(r => r.category === args.category) : records
const breakdown = {}
for (const r of filtered) {
breakdown[r.category] = (breakdown[r.category] || 0) + r.amount
}
const total = filtered.reduce((s, r) => s + r.amount, 0)
const list = Object.entries(breakdown).map(([category, amount]) => ({
category,
amount: Math.round(amount * 100) / 100,
count: filtered.filter(r => r.category === category).length,
})).sort((a, b) => b.amount - a.amount)
return { month, total: Math.round(total * 100) / 100, count: filtered.length, breakdown: list }
},
}))
ctx.tools.register(defineTool({
name: 'expense_list',
description: '列出最近记的账(默认最近 10 笔),含日期、分类、金额、说明。',
parameters: {
limit: { type: 'number', description: '返回笔数,默认 10' },
},
output: {
schema: { type: 'object', properties: { total: { type: 'number' }, records: { type: 'array', items: { type: 'object' } } } },
render: (_args, v) => {
const lines = [`🧾 共 ${v.total} 笔记录:`]
for (const r of v.records) {
lines.push(` ${r.date} ${r.category} ${money(r.amount)} - ${r.description}`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
async execute(args) {
const records = loadLedger().sort((a, b) => (a.date === b.date ? b.id - a.id : a.date < b.date ? 1 : -1))
const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 100)
return { total: records.length, records: records.slice(0, limit) }
},
}))
}
```
```
Error: evolution "expense-tracker" failed to mount: unsupported JSON schema: schema.additionalProperties must be explicitly true or false
The source is kept at /Users/jinqiu/.dsh/evolve/expense-tracker.mjs but not persisted to the manifest — fix it and call evolve_add again with the same name.
```
输出 schema 需要显式声明 additionalProperties,修复后重新挂载:
### step 3 · 🔧 `evolve_add` {"description": "个人记账:用户随口提到花钱(买菜/加油/吃饭/打车等)就用 expense_add 记录,自动按描述分类(买菜/吃饭→餐饮,加油/打车→交通);用户问\"这个月花了多少/吃饭花了多少\"时用 expense_stats 按月分类统计。数据持久化在账单文件里。", "name": "expense-tracker"}
模型现场编写的插件源码(source 参数)
```js
import { defineTool } from '@deepseek-ai/dsh-tools'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
export const name = 'expense-tracker'
export const inject = ['tools']
const DATA_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'expense-ledger.json')
const CATEGORIES = ['餐饮', '交通', '购物', '居住', '娱乐', '医疗', '教育', '其他']
function loadLedger() {
try {
const raw = fs.readFileSync(DATA_FILE, 'utf8')
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
function saveLedger(records) {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true })
fs.writeFileSync(DATA_FILE, JSON.stringify(records, null, 2))
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function currentMonth() {
return todayStr().slice(0, 7)
}
function inferCategory(desc) {
if (/菜|肉|蛋|奶|米|面|水果|零食|超市|买菜|吃饭|外卖|奶茶|咖啡|早餐|午餐|晚餐|火锅|烧烤|餐厅|食材|海鲜/.test(desc)) return '餐饮'
if (/油|加油|停车|地铁|打车|出租|公交|高铁|火车|机票|飞机|高速|充电|单车/.test(desc)) return '交通'
if (/衣|裤|鞋|包|淘宝|京东|拼多多|网购|护肤|化妆|家电|数码|手机/.test(desc)) return '购物'
if (/房租|租金|水电|燃气|物业|宽带|话费|网费/.test(desc)) return '居住'
if (/电影|游戏|旅游|门票|KTV|健身|会员|视频/.test(desc)) return '娱乐'
if (/药|医院|看病|体检|挂号|牙/.test(desc)) return '医疗'
if (/书|学费|课程|培训|考试/.test(desc)) return '教育'
return '其他'
}
const money = (n) => `¥${Number(n).toFixed(2)}`
export function apply(ctx) {
ctx.tools.register(defineTool({
name: 'expense_add',
description: '记一笔账。用户随口提到花了多少钱(买菜、加油、吃饭、打车等)时调用;自动按描述推断分类,也可指定分类和日期。',
parameters: {
amount: { type: 'number', required: true, description: '金额(人民币元)' },
description: { type: 'string', required: true, description: '花在什么上,如 买菜、加油' },
category: { type: 'string', description: '分类:餐饮/交通/购物/居住/娱乐/医疗/教育/其他,不传则自动推断' },
date: { type: 'string', description: '日期 YYYY-MM-DD,默认今天' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'number' },
date: { type: 'string' },
amount: { type: 'number' },
category: { type: 'string' },
description: { type: 'string' },
},
},
render: (_args, v) => [{ type: 'text', text: `已记账:${v.date} ${v.description} ${money(v.amount)}(${v.category})` }],
},
async execute(args) {
const amount = Number(args.amount)
if (!Number.isFinite(amount) || amount <= 0) throw new Error('金额必须是大于 0 的数字')
const date = /^\d{4}-\d{2}-\d{2}$/.test(args.date || '') ? args.date : todayStr()
const category = args.category && CATEGORIES.includes(args.category) ? args.category : inferCategory(args.description)
const records = loadLedger()
const id = records.length ? Math.max(...records.map(r => r.id)) + 1 : 1
const record = { id, date, amount: Math.round(amount * 100) / 100, category, description: args.description }
records.push(record)
saveLedger(records)
return record
},
}))
ctx.tools.register(defineTool({
name: 'expense_stats',
description: '按月/分类统计支出:总金额、笔数、各分类小计。用户问 这个月花了多少 / 吃饭花了多少 这类问题时调用。',
parameters: {
month: { type: 'string', description: '月份 YYYY-MM,默认当月' },
category: { type: 'string', description: '只看某个分类,如 餐饮' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
month: { type: 'string' },
total: { type: 'number' },
count: { type: 'number' },
breakdown: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
category: { type: 'string' },
amount: { type: 'number' },
count: { type: 'number' },
},
},
},
},
},
render: (_args, v) => {
const lines = [`📊 ${v.month} 支出合计:${money(v.total)}(共 ${v.count} 笔)`]
for (const b of v.breakdown) {
lines.push(` ${b.category}:${money(b.amount)}(${b.count} 笔)`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
async execute(args) {
const month = /^\d{4}-\d{2}$/.test(args.month || '') ? args.month : currentMonth()
const records = loadLedger().filter(r => r.date.startsWith(month))
const filtered = args.category ? records.filter(r => r.category === args.category) : records
const breakdown = {}
for (const r of filtered) {
breakdown[r.category] = (breakdown[r.category] || 0) + r.amount
}
const total = filtered.reduce((s, r) => s + r.amount, 0)
const list = Object.entries(breakdown).map(([category, amount]) => ({
category,
amount: Math.round(amount * 100) / 100,
count: filtered.filter(r => r.category === category).length,
})).sort((a, b) => b.amount - a.amount)
return { month, total: Math.round(total * 100) / 100, count: filtered.length, breakdown: list }
},
}))
ctx.tools.register(defineTool({
name: 'expense_list',
description: '列出最近记的账(默认最近 10 笔),含日期、分类、金额、说明。',
parameters: {
limit: { type: 'number', description: '返回笔数,默认 10' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
total: { type: 'number' },
records: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'number' },
date: { type: 'string' },
category: { type: 'string' },
amount: { type: 'number' },
description: { type: 'string' },
},
},
},
},
},
render: (_args, v) => {
const lines = [`🧾 共 ${v.total} 笔记录:`]
for (const r of v.records) {
lines.push(` ${r.date} ${r.category} ${money(r.amount)} - ${r.description}`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
async execute(args) {
const records = loadLedger().sort((a, b) => (a.date === b.date ? b.id - a.id : a.date < b.date ? 1 : -1))
const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 100)
return { total: records.length, records: records.slice(0, limit) }
},
}))
}
```
```
Evolution "expense-tracker" grown: active. Persisted at /Users/jinqiu/.dsh/evolve/expense-tracker.mjs; new tools are callable from your next step.
```
记账工具已挂载。现在把这两笔记进去:
### step 4 · 🔧 `expense_add` {"amount": 86, "description": "买菜", "date": "2026-08-06"}
```
已记账:2026-08-06 买菜 ¥86.00(餐饮)
```
### step 4 · 🔧 `expense_add` {"amount": 400, "description": "加油", "date": "2026-08-05"}
```
已记账:2026-08-05 加油 ¥400.00(交通)
```
两笔都记好了,验证一下统计功能:
### step 5 · 🔧 `expense_stats` {}
```
📊 2026-08 支出合计:¥486.00(共 2 笔)
交通:¥400.00(1 笔)
餐饮:¥86.00(1 笔)
```
✅ 两笔都记好了,以后你说到花钱我就自动记。
**刚记的账:**
- 2026-08-06(今天)买菜 ¥86.00 → **餐饮**
- 2026-08-05(昨天)加油 ¥400.00 → **交通**
**为此我长出了一个常驻记账工具**(`expense-tracker`,数据持久化保存,重启不丢),三个功能:
| 你说 | 我做什么 |
|---|---|
| "打车花了 30" / "买了个手机 5000" | 自动记入 `expense_add`,按内容自动分类(买菜/吃饭→餐饮,加油/打车→交通,买衣服/网购→购物…) |
| "这个月花了多少?" | `expense_stats` 按月汇总总额、笔数、各分类小计 |
| "吃饭花了多少?" | 同上,只看餐饮分类 |
| "最近记了啥" | `expense_list` 列出最近记录 |
刚才的统计已经验证过:**8 月目前共支出 ¥486,餐饮 ¥86 + 交通 ¥400**。
这个工具会一直留着,哪怕你随口说一句"昨天充话费 50",我也会记下来。月底直接问我就行 👍