Skip to content

Commit bf33776

Browse files
author
linyuan.yang
committed
增加工作区记忆
1 parent 917520b commit bf33776

23 files changed

Lines changed: 814 additions & 252 deletions

File tree

packages/admin/src/components/modals/MemoryListModal.vue

Lines changed: 71 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { computed, ref } from 'vue'
33
import { useI18n } from 'vue-i18n'
44
import { apiFetch } from '@/shared/api'
55
import { store } from '@/shared/store'
6-
import { useToast, SButton, SModal, SBadge } from 'sbot-ui'
6+
import { useToast, SButton, SModal, SBadge, SSelect } from 'sbot-ui'
77
88
interface MemorySummary {
99
slug: string
@@ -14,8 +14,11 @@ interface MemorySummary {
1414
updatedAt: number
1515
lastReadAt: number | null
1616
readCount: number
17+
scope: 'global' | 'workspace'
1718
}
1819
20+
interface WorkspaceScope { key: string; path: string }
21+
1922
interface MemoryJob {
2023
id: number
2124
type: string
@@ -32,6 +35,8 @@ const { show } = useToast()
3235
const visible = ref(false)
3336
const memoryId = ref('')
3437
const labelOverride = ref('')
38+
const workspaceScopes = ref<WorkspaceScope[]>([])
39+
const selectedWorkPath = ref('')
3540
3641
const tab = ref<'memories' | 'jobs'>('memories')
3742
const loading = ref(false)
@@ -44,10 +49,14 @@ const retryingJobId = ref<number | null>(null)
4449
4550
const rows = ref<MemorySummary[]>([])
4651
const jobs = ref<MemoryJob[]>([])
47-
const selectedSlug = ref('')
52+
const selectedKey = ref('')
4853
const selectedBody = ref('')
4954
50-
const selected = computed(() => rows.value.find(m => m.slug === selectedSlug.value) || null)
55+
const rowKey = (m: Pick<MemorySummary, 'scope' | 'slug'>) => `${m.scope}:${m.slug}`
56+
const selected = computed(() => rows.value.find(m => rowKey(m) === selectedKey.value) || null)
57+
const viewQuery = computed(() => selectedWorkPath.value
58+
? `viewScope=workspace&workPath=${encodeURIComponent(selectedWorkPath.value)}`
59+
: 'viewScope=global')
5160
5261
const title = computed(() => {
5362
const profiles: any = store.settings.memoryProfiles || {}
@@ -60,8 +69,22 @@ async function openByMemoryId(id: string | null | undefined, label?: string) {
6069
labelOverride.value = label || ''
6170
tab.value = 'memories'
6271
visible.value = true
63-
if (memoryId.value) await refresh()
64-
else { rows.value = []; jobs.value = []; selectedSlug.value = ''; selectedBody.value = '' }
72+
selectedWorkPath.value = ''
73+
if (memoryId.value) {
74+
await loadScopes()
75+
await refresh()
76+
} else { rows.value = []; jobs.value = []; selectedKey.value = ''; selectedBody.value = '' }
77+
}
78+
79+
async function loadScopes() {
80+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/scopes`)
81+
workspaceScopes.value = (res.data?.workspaces || []) as WorkspaceScope[]
82+
}
83+
84+
async function changeScope() {
85+
selectedKey.value = ''
86+
selectedBody.value = ''
87+
await refresh()
6588
}
6689
6790
async function refresh() {
@@ -73,12 +96,12 @@ async function loadMemories() {
7396
if (!memoryId.value) return
7497
loading.value = true
7598
try {
76-
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/list`)
99+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/list?${viewQuery.value}`)
77100
const list = (res.data?.memories || []) as MemorySummary[]
78101
rows.value = list
79-
const nextSlug = list.find(r => r.slug === selectedSlug.value)?.slug || list[0]?.slug || ''
80-
selectedSlug.value = nextSlug
81-
if (nextSlug) await loadBody(nextSlug)
102+
const next = list.find(r => rowKey(r) === selectedKey.value) || list[0]
103+
selectedKey.value = next ? rowKey(next) : ''
104+
if (next) await loadBody(next)
82105
else selectedBody.value = ''
83106
} catch (e: any) {
84107
show(e.message, 'error')
@@ -91,7 +114,8 @@ async function loadJobs() {
91114
if (!memoryId.value) return
92115
jobsLoading.value = true
93116
try {
94-
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/jobs?limit=50`)
117+
const query = [viewQuery.value, 'limit=50'].join('&')
118+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/jobs?${query}`)
95119
jobs.value = (res.data?.jobs || []) as MemoryJob[]
96120
} catch (e: any) {
97121
show(e.message, 'error')
@@ -100,17 +124,19 @@ async function loadJobs() {
100124
}
101125
}
102126
103-
async function selectMemory(slug: string) {
104-
if (selectedSlug.value === slug && selectedBody.value) return
105-
selectedSlug.value = slug
106-
await loadBody(slug)
127+
async function selectMemory(memory: MemorySummary) {
128+
const key = rowKey(memory)
129+
if (selectedKey.value === key && selectedBody.value) return
130+
selectedKey.value = key
131+
await loadBody(memory)
107132
}
108133
109-
async function loadBody(slug: string) {
110-
if (!memoryId.value || !slug) return
134+
async function loadBody(memory: MemorySummary) {
135+
if (!memoryId.value || !memory.slug) return
111136
bodyLoading.value = true
112137
try {
113-
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/entries/${encodeURIComponent(slug)}`)
138+
const query = [viewQuery.value, `entryScope=${memory.scope}`].join('&')
139+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/entries/${encodeURIComponent(memory.slug)}?${query}`)
114140
selectedBody.value = res.data?.row?.body || ''
115141
} catch (e: any) {
116142
selectedBody.value = ''
@@ -124,7 +150,7 @@ async function runConsolidate() {
124150
if (!memoryId.value || consolidating.value) return
125151
consolidating.value = true
126152
try {
127-
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/consolidate/run`, 'POST', {})
153+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/consolidate/run?${viewQuery.value}`, 'POST', {})
128154
show(t('memory_profiles.consolidate_queued', { id: res.data?.jobId ?? '-' }))
129155
await refresh()
130156
} catch (e: any) {
@@ -138,7 +164,7 @@ async function runReconcile() {
138164
if (!memoryId.value || reconciling.value) return
139165
reconciling.value = true
140166
try {
141-
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/reconcile/run`, 'POST', {})
167+
const res = await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/reconcile/run?${viewQuery.value}`, 'POST', {})
142168
show(t('memory_profiles.reconcile_queued', { id: res.data?.jobId ?? '-' }))
143169
await refresh()
144170
} catch (e: any) {
@@ -166,15 +192,16 @@ async function retryExtractJob(job: MemoryJob) {
166192
}
167193
}
168194
169-
async function deleteMemory(slug: string) {
170-
if (!memoryId.value || !slug || deleting.value) return
171-
if (!window.confirm(t('memory_profiles.confirm_delete_memory', { slug }))) return
195+
async function deleteMemory(memory: MemorySummary) {
196+
if (!memoryId.value || !memory.slug || deleting.value) return
197+
if (!window.confirm(t('memory_profiles.confirm_delete_memory', { slug: memory.slug }))) return
172198
deleting.value = true
173199
try {
174-
await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/entries/${encodeURIComponent(slug)}`, 'DELETE')
200+
const query = [viewQuery.value, `entryScope=${memory.scope}`].join('&')
201+
await apiFetch(`/api/memories/${encodeURIComponent(memoryId.value)}/entries/${encodeURIComponent(memory.slug)}?${query}`, 'DELETE')
175202
show(t('memory_profiles.delete_memory_done'))
176-
if (selectedSlug.value === slug) {
177-
selectedSlug.value = ''
203+
if (selectedKey.value === rowKey(memory)) {
204+
selectedKey.value = ''
178205
selectedBody.value = ''
179206
}
180207
await loadMemories()
@@ -231,8 +258,17 @@ defineExpose({ openByMemoryId })
231258
<SButton :type="tab === 'jobs' ? 'primary' : 'outline'" size="sm" @click="tab = 'jobs'">
232259
{{ t('memory_profiles.viewer_jobs') }}
233260
</SButton>
261+
<SSelect v-model="selectedWorkPath" size="sm" @change="changeScope">
262+
<option value="">{{ t('memory_profiles.scope_global') }}</option>
263+
<option v-for="scope in workspaceScopes" :key="scope.key" :value="scope.path">
264+
{{ t('memory_profiles.scope_workspace_context', { path: scope.path }) }}
265+
</option>
266+
</SSelect>
234267
</div>
235268
<div class="memory-actions">
269+
<SBadge variant="info" size="sm">
270+
{{ t(selectedWorkPath ? 'memory_profiles.operation_scope_workspace' : 'memory_profiles.operation_scope_global') }}
271+
</SBadge>
236272
<SButton type="outline" size="sm" :loading="loading || jobsLoading" @click="refresh">{{ t('common.refresh') }}</SButton>
237273
<SButton type="outline" size="sm" :loading="reconciling" @click="runReconcile">{{ t('memory_profiles.run_reconcile') }}</SButton>
238274
<SButton type="outline" size="sm" :loading="consolidating" @click="runConsolidate">{{ t('memory_profiles.run_consolidate') }}</SButton>
@@ -246,13 +282,16 @@ defineExpose({ openByMemoryId })
246282
<button
247283
v-for="m in rows"
248284
v-else
249-
:key="m.slug"
285+
:key="rowKey(m)"
250286
class="memory-row"
251-
:class="{ active: m.slug === selectedSlug }"
252-
@click="selectMemory(m.slug)"
287+
:class="{ active: rowKey(m) === selectedKey }"
288+
@click="selectMemory(m)"
253289
>
254290
<div class="memory-row-head">
255-
<SBadge :variant="kindVariant(m.kind)" size="xs">{{ m.kind }}</SBadge>
291+
<div class="memory-row-badges">
292+
<SBadge variant="neutral" size="xs">{{ m.scope }}</SBadge>
293+
<SBadge :variant="kindVariant(m.kind)" size="xs">{{ m.kind }}</SBadge>
294+
</div>
256295
<span class="memory-row-slug">{{ m.slug }}</span>
257296
</div>
258297
<div class="memory-row-title">{{ m.title }}</div>
@@ -270,10 +309,11 @@ defineExpose({ openByMemoryId })
270309
<div class="memory-detail-slug">{{ selected.slug }}</div>
271310
</div>
272311
<div class="memory-detail-badges">
312+
<SBadge variant="neutral" size="sm">{{ selected.scope }}</SBadge>
273313
<SBadge :variant="kindVariant(selected.kind)" size="sm">{{ selected.kind }}</SBadge>
274314
<SBadge variant="neutral" size="sm">{{ t('memory_profiles.evidence') }} {{ selected.evidenceCount }}</SBadge>
275315
<SBadge variant="neutral" size="sm">{{ t('memory_profiles.read_count') }} {{ selected.readCount }}</SBadge>
276-
<SButton type="danger" size="sm" :loading="deleting" @click="deleteMemory(selected.slug)">
316+
<SButton type="danger" size="sm" :loading="deleting" @click="deleteMemory(selected)">
277317
{{ t('memory_profiles.delete_memory') }}
278318
</SButton>
279319
</div>
@@ -379,6 +419,7 @@ defineExpose({ openByMemoryId })
379419
}
380420
381421
.memory-row-head,
422+
.memory-row-badges,
382423
.memory-row-meta,
383424
.memory-detail-badges,
384425
.memory-job-head,

packages/admin/src/i18n/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,10 @@ export default {
548548
viewer_title: 'Current Memories: {name}',
549549
viewer_memories: 'Memories',
550550
viewer_jobs: 'Background Jobs',
551+
scope_global: 'Global Memory Only',
552+
scope_workspace_context: 'Workspace + Global: {path}',
553+
operation_scope_global: 'Actions: Global',
554+
operation_scope_workspace: 'Actions: Current Workspace',
551555
loading: 'Loading...',
552556
no_memories: 'No memories',
553557
no_body: 'No body',

packages/admin/src/i18n/zh.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,10 @@ export default {
548548
viewer_title: '当前长期记忆:{name}',
549549
viewer_memories: '长期记忆',
550550
viewer_jobs: '后台任务',
551+
scope_global: '仅全局记忆',
552+
scope_workspace_context: '工作区 + 全局:{path}',
553+
operation_scope_global: '操作目标:全局',
554+
operation_scope_workspace: '操作目标:当前工作区',
551555
loading: '加载中...',
552556
no_memories: '暂无长期记忆',
553557
no_body: '暂无正文',

packages/admin/src/views/automation/MemoryProfilesView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async function runConsolidate(id: string) {
153153
if (consolidating.value[id]) return
154154
consolidating.value[id] = true
155155
try {
156-
const res = await apiFetch(`/api/memories/${encodeURIComponent(id)}/consolidate/run`, 'POST', {})
156+
const res = await apiFetch(`/api/memories/${encodeURIComponent(id)}/consolidate/run?viewScope=global`, 'POST', {})
157157
show(t('memory_profiles.consolidate_queued', { id: res.data?.jobId ?? '-' }))
158158
} catch (e: any) {
159159
show(e.message, 'error')

packages/docs-site/guide/memory.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ Think of it as the agent learning from every conversation without you having to
1717
- **Reconcile** — re-indexes and prunes stale entries
1818
4. **Delete** — removed memories are moved to `.archive/` and can be recovered.
1919

20+
## Global and Workspace Memory
21+
22+
One Memory Profile contains two memory layers:
23+
24+
- **Global** — user preferences, machine context, and general workflows that apply across projects.
25+
- **Workspace** — project paths, conventions, module maps, build steps, and architecture decisions isolated by the current `workPath`.
26+
27+
A channel still selects only one Memory Profile. Each turn reads global memory together with the current workspace memory. Different `workPath` values use separate databases and search indexes, so they cannot search or write one another's entries. Sessions without a configured `workPath`, including ordinary Web conversations, share `~/.sbot/workspace` as their default memory workspace instead of writing everything into global memory. The Memory Profile viewer can switch between global memory and previously created workspaces.
28+
2029
## Configuration
2130

2231
A **Memory Profile** defines how memories are extracted and read. Sidebar → **Memory Profiles** → New:

packages/docs-site/zh/guide/memory.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ Memory 是 Agent 的自动长期记忆。后台 **MemoryLLM** 会在每次对话
1717
- **Reconcile(校正)** —— 重建索引、清理陈旧条目
1818
4. **删除** —— 被删除的记忆移到 `.archive/`,可恢复。
1919

20+
## 全局与工作区记忆
21+
22+
一个 Memory Profile 同时包含两层记忆:
23+
24+
- **全局** —— 跨项目生效的用户偏好、机器环境和通用工作方式。
25+
- **工作区** —— 按当前 `workPath` 隔离的项目路径、约定、模块关系、构建步骤和架构决策。
26+
27+
频道仍然只需选择一个 Memory Profile。每轮对话会同时读取全局记忆和当前工作区记忆;不同 `workPath` 使用独立的数据库和检索索引,不会互相搜索或写入。未配置 `workPath` 的会话(包括普通 Web 对话)统一使用 `~/.sbot/workspace` 作为默认记忆工作区,不会把所有内容都写进全局记忆。Memory Profiles 的查看窗口可以在全局和已创建的工作区之间切换。
28+
2029
## 配置项
2130

2231
**Memory Profile** 定义记忆如何被提取与读取。侧栏 → **Memory Profiles** → 新建:

packages/sbot.commons/src/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export interface EmbeddingConfig {
7878
/**
7979
* Memory(skill 风格记忆系统)配置。
8080
*
81-
* 一个 memoryProfile = 一组共享的长期记忆(一个目录 + 一个 SQLite 文件)
81+
* 一个 memoryProfile = 一个逻辑长期记忆空间:包含全局记忆,以及按 workPath 隔离的工作区记忆
8282
* 写入由后台 MemoryWriterWorker 跑,读取通过 read_memory / search_memory 工具 + menu 注入。
8383
*
8484
* channel.memory / sessionProfile.memory 字段的 UUID 在 memoryProfiles 查找。

packages/sbot/prompts/memory/reader/default.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ Never try to edit or delete an entry yourself.
88

99
{{ memory_menu }}
1010

11-
Each line is `- [kind; evidence=N] slug — title`, where the title is the whole entry
12-
compressed to one line, written to be actionable as it stands, and `evidence=N` is how
11+
Each line is `- [scope; kind; evidence=N] slug — title`, where `scope` is `global`
12+
or `workspace`, the title is the whole entry compressed to one line, and `evidence=N` is how
1313
many separate past conversations mentioned or reinforced it. So treat the menu as
1414
content, not as an index: reach for a tool only when you need more than that line —
1515
exact wording, the reasoning behind it, edge cases, or an entry not listed above.
1616

1717
### How to use
1818

19-
- **`read_memory(slug)`** — full body of one entry by its exact slug. Use when the user
19+
- **`read_memory(slug, scope)`** — full body of one entry by its exact slug and required scope. Use when the user
2020
mentions a topic that **clearly matches an entry above** and the title alone isn't
2121
enough to act on.
2222
- **`search_memory(query)`** — BM25 over all bodies. Use when the topic isn't visible in
@@ -35,6 +35,9 @@ file, choose a format, run a build, name a thing — check whether an entry cons
3535
it should be done, and follow it unprompted. A recorded preference that the user has to
3636
restate is a failed memory.
3737

38+
When global and workspace memories conflict, the workspace entry is more specific and
39+
wins for the current workPath. Never apply a workspace entry to another workPath.
40+
3841
### Say it out loud whenever memory is in play
3942

4043
- **You used an entry**: say so in one short phrase ("based on a recorded preference:

packages/sbot/prompts/memory/writer/default.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ something does not, no matter how confidently it restates what the user "wants".
2222

2323
You receive two things:
2424

25-
1. **Existing memories**a list of `{slug, kind, evidence, title}`. NO bodies, so you
25+
1. **Existing memories**global and current-workspace lists of `{scope, slug, kind, evidence, title}`. NO bodies, so you
2626
judge overlap from the title alone.
2727
- When a title looks like it might already cover your candidate but you cannot be
2828
sure, `update` it instead of creating a sibling: a near-duplicate pair is harder to
@@ -68,7 +68,7 @@ Every operation is one of:
6868

6969
## `create`
7070
A genuinely new fact, with no existing slug that overlaps. Required fields:
71-
`slug`, `title`, `body`. Optional: `kind`. If the slug turns out to already exist, the
71+
`slug`, `title`, `body`, `scope`. Optional: `kind`. If the slug turns out to already exist, the
7272
system falls back to `update` and merges — so a near-miss is recoverable, but choosing
7373
`update` yourself when you suspect overlap gives a better merge.
7474

@@ -97,10 +97,10 @@ system falls back to `update` and merges — so a near-miss is recoverable, but
9797

9898
## `update`
9999
An existing memory needs revision because new information arrived. Required: `slug`,
100-
`reason`. Optional: `title`, `body`, `kind`, `bodyMode` — any subset; omitted fields
100+
`reason`, `scope`. Optional: `title`, `body`, `kind`, `bodyMode` — any subset; omitted fields
101101
keep their current value.
102102

103-
Use it when the fact changed (a deadline moved), more nuance is now known, or the
103+
Use the entry's existing `scope`. Use it when the fact changed (a deadline moved), more nuance is now known, or the
104104
existing title was misleading. Do NOT use it to fold two unrelated topics into one
105105
entry — that's two `create`s. `reason` is logged for audit.
106106

@@ -116,7 +116,8 @@ entry — that's two `create`s. `reason` is logged for audit.
116116
the entry ends up stating two different rules.
117117

118118
## `delete`
119-
An existing memory is wrong, superseded, or no longer relevant. Required: `slug`, `reason`.
119+
An existing memory is wrong, superseded, or no longer relevant. Required: `slug`, `reason`,
120+
`scope`; use the entry's existing scope value.
120121

121122
Use it when the fact is now false, the project moved on, or the user explicitly asked
122123
you to forget it. Bias toward NOT deleting — if uncertain, `update` instead. Archived

packages/sbot/skills/config-guide/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ sbot 运行时配置目录是 `~/.sbot/`。处理配置问题时只需要围绕
3535
| `savers/<saverId>/` | saver 历史数据 |
3636
| `notes/<noteId>.db` / `notes/<noteId>/` | note SQLite 与检索缓存 |
3737
| `wiki/<wikiId>/` | wiki 数据 |
38-
| `memories/<memoryId>/` | memoryProfile 的长期记忆数据 |
38+
| `memories/<memoryId>/` | memoryProfile 的全局记忆;`workspaces/<pathHash>/` 下是按 workPath 隔离的记忆 |
3939
| `agendas/<agendaId>/agenda.db` | agendaProfile 的事项数据 |
4040
| `profiles/<threadId>/settings.json` | SessionService 的线程级设置,不等同于 Web profile 管理配置 |
4141
| `database.sqlite` | channel session、session_profile、usage 等数据库表 |
@@ -258,10 +258,10 @@ DELETE /api/settings/agendaProfiles/:id
258258

259259
运行数据:
260260

261-
- memory:`~/.sbot/memories/<memoryId>/`
261+
- memory:`~/.sbot/memories/<memoryId>/`(全局)+ `workspaces/<pathHash>/`(当前 workPath 独有)
262262
- agenda:`~/.sbot/agendas/<agendaId>/agenda.db`
263263

264-
`enabled: false` 表示引用可以存在,但运行时不启用。`memoryProfiles.writerModel` 必须指向 `models` 中的 UUID。`agendaProfiles.syncModel` 可选;为空时同步抽取不启用。
264+
`enabled: false` 表示引用可以存在,但运行时不启用。`memoryProfiles.writerModel` 必须指向 `models` 中的 UUID。频道仍只引用一个 memoryProfile;同一个 MemoryService 会同时读取全局记忆和当前 workPath 记忆,项目内容不会进入其他 workPath。未配置 workPath 的会话(包括普通 Web 对话)统一使用 `~/.sbot/workspace` 作为默认记忆工作区。`agendaProfiles.syncModel` 可选;为空时同步抽取不启用。
265265

266266
Agenda item 管理走:
267267

0 commit comments

Comments
 (0)