memory-eternal 0.14.3 → 0.16.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.
@@ -1,119 +1,179 @@
1
- # dsh-TUI plugin — `/memory`
2
-
3
- 在 dsh-TUI 里增加一个用户命令:
4
-
5
- ```text
6
- /memory
7
- ```
8
-
9
- 执行后确保 `memory-eternal` 的本地 Web 服务存活,并打开同一套 Memory Atlas Web UI。
10
-
11
- 另一个命令:
12
-
13
- ```text
14
- /audit
15
- ```
16
-
17
- 在 dsh-tui 内直接打开**原生审核场景**:读取真实待审队列、`s` 全选、`a` 批准、`r` 驳回、`Esc` 退出。它不打开浏览器,也不复制 Web UI。
18
-
19
- ## 先看效果(不需要 dsh-tui)
20
-
21
- 完整 TUI 原型:
22
-
23
- ```bash
24
- node dsh-tui-plugin/demo.mjs
25
- ```
26
-
27
- 包含:
28
-
29
- - `1` 记忆库:搜索、类型、今日回看、读卡
30
- - `2` 待审:类型、全选、批量批准/驳回、判词
31
- - `3` 知识星图:连接簇、相关卡
32
- - `4` 运行状态:知识库/备份/沉淀日志
33
- - `5` 回收站:恢复、彻底删除
34
- - `6` 设置:开关、主题
35
- - `Enter` 读卡器
36
- - `/` 搜索
37
- - `?` 帮助
38
-
39
- 静态预览单页:
40
-
41
- ```bash
42
- node dsh-tui-plugin/demo.mjs --once --no-color
43
- node dsh-tui-plugin/demo.mjs --once --no-color --screen=library
44
- node dsh-tui-plugin/demo.mjs --once --no-color --screen=settings
45
- node dsh-tui-plugin/demo.mjs --once --no-color --screen=audit --select-all
46
- node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader
47
- node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader --scroll=60
48
- ```
49
-
50
- 按键:
51
-
52
- - `1–6` 切换页面
53
- - `Tab` 循环页面
54
- - `↑↓` / `j k` 移动
55
- - `←→` / `t` 切换类型
56
- - `Enter` 读卡 / 切换设置
57
- - `L` 直接打开长文读卡器;`j/k` 滚动,`PgUp/PgDn` 翻页,`g/G` 首尾
58
-
59
- 实时数据模式:
60
-
61
- ```bash
62
- # 只读:读取真实待审卡和正文
63
- node dsh-tui-plugin/demo.mjs --live
64
-
65
- # 写入:允许真实批准 / 驳回,谨慎使用
66
- node dsh-tui-plugin/demo.mjs --live --write
67
- ```
68
-
69
- 实时模式默认走 `http://127.0.0.1:7999/memory-eternal/api`,可用
70
- `MEMORY_ETERNAL_API` 覆盖。API 不可用时自动回退到示例数据。
71
- - `Space` 多选 / 切换设置
72
- - `s` 或 `Ctrl+A` 全选 / 反选
73
- - `a` 批准,`r` 驳回
74
- - 回收站:`r` 恢复,`d` 彻底删除
75
- - `/` 搜索,`Esc` 返回 / 清空
76
- - `?` 帮助,`q` 退出
77
-
78
- 每个页面的底部快捷键会随当前页面更新,例如待审页显示全选/批驳,设置页显示切换,回收站显示恢复/删除。
79
-
80
- `demo-audit.mjs` 保留为纯审核页备份。
81
-
82
- ## 安装
83
-
84
- 在 `memory-eternal` 仓库根目录:
85
-
86
- ```bash
87
- # 本地 profile(推荐先用这个验证)
88
- dsh plugin --profile dsh-tui add ./dsh-tui-plugin
89
- ```
90
-
91
- 或手动:
92
-
93
- 1. 把本目录复制到 dsh-TUI profile 的 node_modules;
94
- 2. 在 profile 的 `cordis.patch.yml` 中加入:
95
-
96
- ```yaml
97
- - insert:
98
- - id: memory-eternal-tui
99
- name: 'memory-eternal-tui'
100
- ```
101
-
102
- 3. 重启 dsh-TUI。
103
-
104
- ## 使用
105
-
106
- ```text
107
- /memory
108
- ```
109
-
110
- - 优先执行 `dsh-memory open`(确保 7999 存活 + 打开浏览器)
111
- - 找不到 CLI 时直接打开 `http://127.0.0.1:7999/`
112
- - 不会在 TUI 内嵌 WebView,也不复制第二套 UI
113
- - 审批仍在 Web 里由人完成,不新增 agent 工具
114
-
115
- ## 要求
116
-
117
- - `memory-eternal` 已安装在当前机器
118
- - 终端能执行 `dsh-memory`(否则走 URL 兜底)
119
- - dsh-TUI >= 0.9.0(commands seam)
1
+ # dsh-TUI plugin — `/memory` 与 `/audit`
2
+
3
+ 在 dsh-TUI 里增加一个用户命令:
4
+
5
+ ```text
6
+ /memory
7
+ ```
8
+
9
+ 执行后确保 `memory-eternal` 的本地 Web 服务存活,并打开同一套 Memory Atlas Web UI。
10
+
11
+ 另一个命令:
12
+
13
+ ```text
14
+ /audit
15
+ ```
16
+
17
+ 在 dsh-tui 内直接打开**原生审核场景**:读取真实待审队列,判词面板显示卡片来源与 `reason`(区分「蒸馏兜底原文卡」和真正值得审的内容)、`severity`;`s` 全选、`a` 批准、`r` 驳回(批准/驳回都先进入二次确认,驳回可顺手填理由),`Esc` 取消/退出。它不打开浏览器,也不复制 Web UI。详见下文 [「`/audit` 审核闭环」](#audit-审核闭环issue-50)。
18
+
19
+ ## 先看效果(不需要 dsh-tui)
20
+
21
+ 完整 TUI 原型:
22
+
23
+ ```bash
24
+ node dsh-tui-plugin/demo.mjs
25
+ ```
26
+
27
+ 包含:
28
+
29
+ - `1` 记忆库:搜索、类型、今日回看、读卡
30
+ - `2` 待审:类型、全选、批量批准/驳回、判词
31
+ - `3` 知识星图:连接簇、相关卡
32
+ - `4` 运行状态:知识库/备份/沉淀日志
33
+ - `5` 回收站:恢复、彻底删除
34
+ - `6` 设置:开关、主题
35
+ - `Enter` 读卡器
36
+ - `/` 搜索
37
+ - `?` 帮助
38
+
39
+ 静态预览单页:
40
+
41
+ ```bash
42
+ node dsh-tui-plugin/demo.mjs --once --no-color
43
+ node dsh-tui-plugin/demo.mjs --once --no-color --screen=library
44
+ node dsh-tui-plugin/demo.mjs --once --no-color --screen=settings
45
+ node dsh-tui-plugin/demo.mjs --once --no-color --screen=audit --select-all
46
+ node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader
47
+ node dsh-tui-plugin/demo.mjs --once --no-color --screen=reader --scroll=60
48
+ ```
49
+
50
+ 按键:
51
+
52
+ - `1–6` 切换页面
53
+ - `Tab` 循环页面
54
+ - `↑↓` / `j k` 移动
55
+ - `←→` / `t` 切换类型
56
+ - `Enter` 读卡 / 切换设置
57
+ - `L` 直接打开长文读卡器;`j/k` 滚动,`PgUp/PgDn` 翻页,`g/G` 首尾
58
+
59
+ 实时数据模式:
60
+
61
+ ```bash
62
+ # 只读:读取真实待审卡和正文
63
+ node dsh-tui-plugin/demo.mjs --live
64
+
65
+ # 写入:允许真实批准 / 驳回,谨慎使用
66
+ node dsh-tui-plugin/demo.mjs --live --write
67
+ ```
68
+
69
+ 实时模式默认走 `http://127.0.0.1:7999/memory-eternal/api`,可用
70
+ `MEMORY_ETERNAL_API` 覆盖。API 不可用时自动回退到示例数据。
71
+ - `Space` 多选 / 切换设置
72
+ - `s` 或 `Ctrl+A` 全选 / 反选
73
+ - `a` 批准,`r` 驳回(都先进入二次确认:`Enter` 才提交,`Esc` 取消;驳回确认态里可直接输入理由)
74
+ - 回收站:`r` 恢复,`d` 彻底删除
75
+ - `/` 搜索,`Esc` 返回 / 清空
76
+ - `?` 帮助,`q` 退出
77
+
78
+ 每个页面的底部快捷键会随当前页面更新,例如待审页显示全选/批驳,设置页显示切换,回收站显示恢复/删除。
79
+
80
+ `demo-audit.mjs` 保留为纯审核页备份。
81
+
82
+ ## 安装
83
+
84
+ 在 `memory-eternal` 仓库根目录:
85
+
86
+ ```bash
87
+ # 本地 profile(推荐先用这个验证)
88
+ dsh plugin --profile dsh-tui add ./dsh-tui-plugin
89
+ ```
90
+
91
+ 或手动:
92
+
93
+ 1. 把本目录复制到 dsh-TUI profile 的 node_modules;
94
+ 2. 在 profile 的 `cordis.patch.yml` 中加入:
95
+
96
+ ```yaml
97
+ - insert:
98
+ - id: memory-eternal-tui
99
+ name: 'memory-eternal-tui'
100
+ ```
101
+
102
+ 3. 重启 dsh-TUI。
103
+
104
+ ## 使用
105
+
106
+ ```text
107
+ /memory
108
+ ```
109
+
110
+ - 优先执行 `dsh-memory open`(确保 7999 存活 + 打开浏览器)
111
+ - 找不到 CLI 时直接打开 `http://127.0.0.1:7999/`
112
+ - 不会在 TUI 内嵌 WebView,也不复制第二套 UI
113
+ - 审批仍在 Web 里由人完成,不新增 agent 工具
114
+
115
+ ## `/audit` 审核闭环(issue #50)
116
+
117
+ 装到 dsh-tui 之后,输入 `/audit` 打开原生审核场景(或 `/memory` 打开 Web 版)。
118
+ 两者用的是**同一份数据**:待审队列来自 `GET /cards?status=pending&limit=300`,批准/驳回走
119
+ `POST /audit/approve`、`POST /audit/reject`,最终都落到 `setCardStatus()` + `audit_log`,
120
+ 不新增 agent 工具、不绕过审核守卫。
121
+
122
+ ### 判词面板:先看 `reason`
123
+
124
+ 右侧面板按顺序显示:`kind · 时间 · 来源 · 严重度` → `依据:<reason>` → 兜底标记 → 标题/摘要。
125
+ `reason` 由卡片 DB 的 `reason` 字段带出(`/cards` 已返回),常见值:
126
+
127
+ | reason | 含义 | 审核建议 |
128
+ | --- | --- | --- |
129
+ | `AI 自动沉淀(蒸馏卡)` | LLM 蒸馏出的知识卡 | 正常审 |
130
+ | `AI 自动沉淀(原文卡)` | **蒸馏失败/不可用时的降级原文卡** | 优先复核,多数可驳回 |
131
+ | `手动创建` | `/write` 手动写入 | 正常审 |
132
+ | `多卡合并` | 优化合并产生 | 正常审 |
133
+
134
+ - 兜底原文卡额外显示 `⚠ 蒸馏兜底原文卡(无 LLM 精炼,优先复核)`;
135
+ - 没有 `reason` 的卡片不显示该行(不留空行);
136
+ - `severity` 为默认值 `info` 时不显示,非默认值(如 `warning`)才出现在第一行。
137
+
138
+ ### 键位
139
+
140
+ | 键 | 作用 |
141
+ | --- | --- |
142
+ | `↑` / `↓` | 移动选中项(列表窗口跟着走,标题显示当前屏 `起-止/总数`) |
143
+ | `Space` | 多选 / 取消选中当前卡 |
144
+ | `s` / `Ctrl+A` | 全选 / 全不选 |
145
+ | `a` | 进入**批准确认态**(底栏:`确认批准 N 张?Enter 确认 / Esc 取消`) |
146
+ | `r` | 进入**驳回确认态**并输入理由(底栏:`驳回 N 张 · 理由(可空):… Enter 确认 / Esc 取消`) |
147
+ | `Enter` | 确认态下提交(驳回时把输入的理由一起提交,批量驳回共用同一理由) |
148
+ | `Esc` | 确认态:取消回列表;列表:退出场景 |
149
+ | 其他可见字符 | 仅驳回确认态:拼进理由(可空;上限 120 字) |
150
+
151
+ 确认目标在进入确认态时**冻结**:确认期间列表/选择变化不会改变这次提交的对象;
152
+ 提交失败会留在确认态,排好网络/权限后可直接 `Enter` 重试。
153
+
154
+ ### 驳回理由写到哪
155
+
156
+ `POST /audit/reject` 现在接受可选 `reason`(JSON body 或 query):
157
+
158
+ ```js
159
+ // body: { path, reason? } 或 GET /audit/reject?path=...&reason=...
160
+ await setCardStatus(vaultRoot, rel, 'rejected', { changedBy: 'user', reason })
161
+ ```
162
+
163
+ - 有理由 → 写进 `audit_log.reason`(`changed_by='user'`);
164
+ - 不传理由 → `reason` 仍是空串,状态流转与以前一致(唯一区别是 `changed_by` 从默认
165
+ `'system'` 变成 `'user'`,因为这是人点的);
166
+ - 响应里回显 `reason`,方便调用方自检。
167
+
168
+ ### 本机没有 dsh-tui 宿主时
169
+
170
+ 本插件只做 dsh-tui 宿主注册(`tuiPluginHost` / `tuiScenes`)。没有宿主时 `/audit` 不会被加载,
171
+ 能用的是仓库里的 TUI 原型(`node dsh-tui-plugin/demo.mjs --live`)。
172
+ 纯函数(`cardMeta` / `reasonLine` / `reasonFlag` / `positionLabel` / `confirmLabel` / `auditPayload`)
173
+ 可以在没有宿主的环境里直接被 `tests/tui-audit-scene.test.mjs` 验证。
174
+
175
+ ## 要求
176
+
177
+ - `memory-eternal` 已安装在当前机器
178
+ - 终端能执行 `dsh-memory`(否则走 URL 兜底)
179
+ - dsh-TUI >= 0.9.0(commands seam)
@@ -2,6 +2,7 @@
2
2
  // Memory Atlas TUI —— 完整交互原型
3
3
  // node dsh-tui-plugin/demo.mjs
4
4
  // 静态帧:node dsh-tui-plugin/demo.mjs --once --no-color
5
+ // 定位列表:node dsh-tui-plugin/demo.mjs --once --no-color --screen=library --index=900
5
6
  // 全选预览:node dsh-tui-plugin/demo.mjs --once --no-color --select-all
6
7
  //
7
8
  // 示例数据,不连接真实 vault,不写任何卡片。
@@ -174,11 +175,13 @@ const SETTINGS = [
174
175
  const argv = new Set(process.argv.slice(2))
175
176
  const once = argv.has('--once')
176
177
  const selectAllAtStart = argv.has('--select-all')
177
- const liveMode = argv.has('--live')
178
+ const liveMode = !argv.has('--demo') && !argv.has('--sample')
178
179
  const writeMode = argv.has('--write')
179
180
  const API = process.env.MEMORY_ETERNAL_API || 'http://127.0.0.1:7999/memory-eternal/api'
180
181
  const screenArg = (process.argv.find((a) => a.startsWith('--screen=')) || '').split('=')[1]
181
182
  const scrollArg = Number((process.argv.find((a) => a.startsWith('--scroll=')) || '').split('=')[1] || 0)
183
+ // --index=N:设定初始选中项;列表窗口会跟着它滚动,便于自动化验收位置
184
+ const indexArg = Number((process.argv.find((a) => a.startsWith('--index=')) || '').split('=')[1])
182
185
  const useColor = !argv.has('--no-color') && !process.env.NO_COLOR
183
186
 
184
187
  const sgr = (code, text) => (useColor ? code + text + C.reset : text)
@@ -227,13 +230,55 @@ const wrap = (text, n) => {
227
230
  function panel(title, lines, w, h, accent = C.border) {
228
231
  const inner = Math.max(1, w - 2)
229
232
  const t = ' ' + title + ' '
230
- const top = sgr(accent, '╭─') + sgr(accent, t) + sgr(accent, '─'.repeat(Math.max(0, inner - width(t))) + '╮')
233
+ // 顶框总宽必须等于内框 + 左右边框(= w):标题两侧各多占 1 列,否则右边会多出 1 列
234
+ const top = sgr(accent, '╭─') + sgr(accent, t) + sgr(accent, '─'.repeat(Math.max(0, inner - width(t) - 1)) + '╮')
231
235
  const bottom = sgr(accent, '╰' + '─'.repeat(inner) + '╯')
232
236
  const body = []
233
237
  for (let i = 0; i < h - 2; i++) body.push(sgr(C.border, '│') + pad(clip(lines[i] || '', inner), inner) + sgr(C.border, '│'))
234
238
  return [top, ...body, bottom].join('\n')
235
239
  }
236
240
 
241
+ // ── 列表窗口 + 滚动条 ────────────────────────────────────────────────
242
+ // 记忆库 / 待审队列不再全量渲染:只渲染 listTop 起的一屏,右侧 1 列画
243
+ // 滚动条(滑轨 │ / 滑块 █),并保证 state.index 始终落在窗口内。
244
+ const SCROLL_THUMB = '█'
245
+ const SCROLL_TRACK = '│'
246
+
247
+ function windowInfo(state, total, visible) {
248
+ const count = Math.max(0, Number.isFinite(total) ? Math.floor(total) : 0)
249
+ const vis = Math.max(1, Number.isFinite(visible) ? Math.floor(visible) : 1)
250
+ let index = Math.max(0, Math.min(Math.floor(Number(state.index) || 0), Math.max(0, count - 1)))
251
+ let top = Math.max(0, Math.floor(Number(state.listTop) || 0))
252
+ if (index < top) top = index
253
+ else if (index >= top + vis) top = index - vis + 1
254
+ top = Math.min(top, Math.max(0, count - vis))
255
+ state.index = index
256
+ state.listTop = top
257
+ return { total: count, visible: vis, index, top, end: Math.min(count, top + vis) }
258
+ }
259
+
260
+ function scrollChar(row, rows, view) {
261
+ if (!view || view.total <= view.visible || rows <= 0) return ' '
262
+ const thumbH = Math.min(rows, Math.max(1, Math.round((view.visible / view.total) * rows)))
263
+ const maxTop = Math.max(1, view.total - view.visible)
264
+ const pos = Math.round((Math.min(view.top, maxTop) / maxTop) * (rows - thumbH))
265
+ return row >= pos && row < pos + thumbH ? sgr(C.ember, SCROLL_THUMB) : sgr(C.faint, SCROLL_TRACK)
266
+ }
267
+
268
+ // panel 会把每行 clip/pad 到 inner 宽度:这里先把正文压到 inner-1,再补滚动条列,
269
+ // 所以右边框不会被挤掉;不需要滚动时该列留空。
270
+ function scrollPanel(title, lines, w, h, accent, view) {
271
+ const inner = Math.max(1, w - 2)
272
+ const rows = Math.max(0, h - 2)
273
+ if (!view) return panel(title, lines, w, h, accent)
274
+ const cw = Math.max(1, inner - 1)
275
+ const composed = []
276
+ for (let i = 0; i < rows; i++) {
277
+ composed.push(pad(clip(lines[i] || '', cw), cw) + scrollChar(i, rows, view))
278
+ }
279
+ return panel(title, composed, w, h, accent)
280
+ }
281
+
237
282
  const kindOf = (c) => KIND[c.kind] || KIND.project
238
283
  const filtered = (state) => {
239
284
  const q = state.query.trim().toLowerCase()
@@ -261,14 +306,18 @@ function searchRow(state, w) {
261
306
  return ' ' + sgr(C.faint, '/ ') + sgr(state.searchMode ? C.text : C.faint, text) + ' ' + sgr(C.faint, 'Enter 确认 · Esc 清空')
262
307
  }
263
308
 
264
- function queueLines(cards, state, w) {
309
+ function queueLines(cards, state, w, view) {
265
310
  if (!cards.length) return ['', sgr(C.green, ' ✓ 没有匹配的卡片'), '', sgr(C.dim, ' 按 Esc 清空搜索 / 筛选')]
266
311
  const lines = []
267
312
  const chosen = cards.filter((c) => state.marked.has(c.id)).length
268
313
  const all = cards.length > 0 && chosen === cards.length
269
314
  lines.push(` ${all ? sgr(C.ember, '▣') : sgr(C.faint, '☐')} ${sgr(C.dim, all ? '反选' : '全选')} ${cards.length} ${chosen ? sgr(C.amber, '已选 ' + chosen) : sgr(C.faint, '已选 0')} ${keycap('s', all ? '反选' : '全选')}`)
270
315
  lines.push('')
271
- cards.forEach((card, i) => {
316
+ // 只渲染窗口内的项:view.top..view.end(0 张 / 全量时退化为原行为)
317
+ const from = view ? view.top : 0
318
+ const to = view ? view.end : cards.length
319
+ for (let i = from; i < to; i++) {
320
+ const card = cards[i]
272
321
  const k = kindOf(card)
273
322
  const selected = i === state.index
274
323
  const marked = state.marked.has(card.id)
@@ -282,7 +331,7 @@ function queueLines(cards, state, w) {
282
331
  if (selected) meta = tone(pad(clip(meta, w), w), '', C.selectedBg)
283
332
  lines.push(clip(meta, w))
284
333
  lines.push('')
285
- })
334
+ }
286
335
  return lines
287
336
  }
288
337
 
@@ -313,11 +362,17 @@ function previewLines(card, w) {
313
362
 
314
363
  function auditView(state, w, h) {
315
364
  const cards = filtered(state)
316
- const selected = cards[state.index]
317
365
  const qw = Math.max(40, Math.floor((w - 1) * 0.4))
318
366
  const pw = w - qw - 1
319
367
  const ph = h - 2
320
- const q = panel(`队列 ${cards.length}${state.marked.size ? ' ◉' + state.marked.size : ''}`, queueLines(cards, state, qw - 2), qw, ph)
368
+ // 队列面板内高 = ph-2;前 2 行是全选/已选说明,其余按每卡 3 行开窗
369
+ const rows = Math.max(0, ph - 2)
370
+ const visible = Math.max(1, Math.floor(Math.max(0, rows - 2) / 3) || 1)
371
+ const view = windowInfo(state, cards.length, visible)
372
+ state.listPage = view.visible
373
+ const selected = cards[view.index]
374
+ const pos = cards.length ? ` · ${view.top + 1}-${view.end}/${cards.length}` : ''
375
+ const q = scrollPanel(`队列 ${cards.length}${state.marked.size ? ' ◉' + state.marked.size : ''}${pos}`, queueLines(cards, state, qw - 3, view), qw, ph, C.border, view)
321
376
  const p = panel('判词', previewLines(selected, pw - 2), pw, ph, C.ember)
322
377
  const ql = q.split('\n'), pl = p.split('\n')
323
378
  const out = [typeRow(state, w), '']
@@ -336,19 +391,33 @@ function cardRow(card, selected, w, compact) {
336
391
 
337
392
  function libraryView(state, w, h) {
338
393
  const cards = filtered(state)
339
- const lines = [typeRow(state, w), '', searchRow(state, w), '']
394
+ const inner = Math.max(1, w - 2)
395
+ // 右侧留 1 列给滚动条:正文宽度 = 内框宽度 - 1
396
+ const cw = Math.max(1, inner - 1)
397
+ const bodyRows = Math.max(0, h - 2)
340
398
  const feature = cards[0] || CARDS[0]
341
- lines.push(`${sgr(C.ember, '✦ 今日回看')} ${sgr(C.text, feature.title)}`)
342
- lines.push(` ${sgr(C.dim, feature.summary)}`)
343
- lines.push(` ${feature.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' ')}`)
344
- lines.push('', sgr(C.border, '─'.repeat(Math.max(1, w - 2))), '')
345
- lines.push(`${sgr(C.dim, '全部记忆')} ${sgr(C.faint, cards.length + ' 张 · 按 Enter 读卡')}`)
346
- lines.push('')
347
- cards.forEach((card, i) => {
348
- lines.push(...cardRow(card, i === state.index, w - 2, true))
399
+ const header = [
400
+ typeRow(state, cw), '',
401
+ searchRow(state, cw), '',
402
+ `${sgr(C.ember, '✦ 今日回看')} ${sgr(C.text, feature.title)}`,
403
+ ` ${sgr(C.dim, feature.summary)}`,
404
+ ` ${feature.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' ')}`,
405
+ '', sgr(C.border, '─'.repeat(Math.max(1, cw))), '',
406
+ `${sgr(C.dim, '全部记忆')} ${sgr(C.faint, cards.length + ' 张 · 按 Enter 读卡')}`,
407
+ '',
408
+ ]
409
+ const listRows = Math.max(0, bodyRows - header.length)
410
+ // 每张卡固定 2 行正文 + 1 行空行;只渲染这一屏
411
+ const visible = Math.max(1, Math.floor(listRows / 3) || 1)
412
+ const view = windowInfo(state, cards.length, visible)
413
+ state.listPage = view.visible
414
+ const lines = header.slice()
415
+ for (let i = view.top; i < view.end; i++) {
416
+ lines.push(...cardRow(cards[i], i === view.index, cw, true))
349
417
  lines.push('')
350
- })
351
- return [panel('记忆库', lines, w, h, C.border)]
418
+ }
419
+ const title = cards.length ? `记忆库 · ${view.top + 1}-${view.end}/${cards.length}` : '记忆库 · 0/0'
420
+ return [scrollPanel(title, lines, w, h, C.border, view)]
352
421
  }
353
422
 
354
423
  function readerView(state, w, h) {
@@ -478,6 +547,8 @@ function helpView(w, h) {
478
547
  ` ${keycap('1')}–${keycap('6')} 记忆库 / 待审 / 星图 / 状态 / 回收 / 设置`,
479
548
  ` ${keycap('Tab')} 切换页面`,
480
549
  ` ${keycap('↑↓')} ${keycap('jk')} 移动`,
550
+ ` ${keycap('PgUp/PgDn')} 列表翻页(记忆库 / 待审)`,
551
+ ` ${keycap('g/G')} 列表首 / 尾`,
481
552
  ` ${keycap('←→')} ${keycap('t')} 切换类型`,
482
553
  ` ${keycap('Enter')} 读卡 / 切换设置`,
483
554
  ` ${keycap('Space')} 多选 / 切换设置`,
@@ -501,16 +572,19 @@ function footer(state, w) {
501
572
  if (state.screen === 'settings') return ' ' + [keycap('↑↓', '移动'), keycap('Enter', '切换'), keycap('Space', '切换'), keycap('Esc', '返回')].join(' ')
502
573
  if (state.screen === 'recycle') return ' ' + [keycap('↑↓', '移动'), keycap('r', '恢复'), keycap('d', '删除')].join(' ')
503
574
  if (state.screen === 'audit' && state.marked.size) return ' ' + [keycap('a', '批准 ' + state.marked.size), keycap('r', '驳回'), keycap('s', '反选'), keycap('Esc', '清空')].join(' ')
504
- if (state.screen === 'audit') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('Space', '多选'), keycap('s', '全选'), keycap('a', '批准'), keycap('r', '驳回'), keycap('Enter', '读卡'), keycap('1-6', '页面'), keycap('?', '帮助')].join(' ')
505
- if (state.screen === 'library') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('/', '搜索'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
575
+ if (state.screen === 'audit') return ' ' + [keycap('↑↓', '移动'), keycap('PgUp/PgDn', '翻页'), keycap('g/G', '首/尾'), keycap('←→', '类型'), keycap('Space', '多选'), keycap('s', '全选'), keycap('a', '批准'), keycap('r', '驳回'), keycap('Enter', '读卡'), keycap('1-6', '页面'), keycap('?', '帮助')].join(' ')
576
+ if (state.screen === 'library') return ' ' + [keycap('↑↓', '移动'), keycap('PgUp/PgDn', '翻页'), keycap('g/G', '首/尾'), keycap('←→', '类型'), keycap('/', '搜索'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
506
577
  if (state.screen === 'graph') return ' ' + [keycap('↑↓', '切换卡片'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
507
578
  if (state.screen === 'status') return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
508
579
  return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
509
580
  }
510
581
 
511
582
  function frame(state) {
512
- const w = Math.max(72, Math.min(useColor ? 140 : 120, process.stdout.columns || 120))
513
- const rows = Math.max(24, process.stdout.rows || 36)
583
+ // 非 TTY(管道 / --once 重定向)时退化到 COLUMNS/LINES,便于自动化验收终端尺寸
584
+ const cols = Number(process.stdout.columns) || Number(process.env.COLUMNS) || 0
585
+ const rowsRaw = Number(process.stdout.rows) || Number(process.env.LINES) || 0
586
+ const w = Math.max(72, Math.min(useColor ? 140 : 120, cols || 120))
587
+ const rows = Math.max(24, rowsRaw || 36)
514
588
  const screen = state.help ? 'help' : state.screen
515
589
  const label = screen === 'help' ? '帮助' : SCREENS[screen]?.label || '记忆库'
516
590
  const totalNow = state.screen === 'library' ? (state.libraryCards || state.cards).length : state.cards.length
@@ -615,6 +689,7 @@ async function loadLive(state) {
615
689
  path: c.path,
616
690
  }))
617
691
  state.index = 0
692
+ state.listTop = 0
618
693
  state.marked.clear()
619
694
  if (selectAllAtStart) state.cards.forEach((c) => state.marked.add(c.id))
620
695
  if (!state.cards.length) {
@@ -639,7 +714,12 @@ async function main() {
639
714
  settings: SETTINGS.map((s) => ({ ...s })), settingsIndex: 0, help: screenArg === 'help', toast: null,
640
715
  live: false, liveError: '', overview: null, libraryCards: CARDS.slice(), graphData: null, graphIndex: 0,
641
716
  }
717
+ state.initialIndex = Number.isFinite(indexArg) && indexArg > 0 ? Math.floor(indexArg) : 0
718
+ state.listTop = 0
719
+ state.listPage = 1
642
720
  if (liveMode) await loadLive(state)
721
+ // --index=N 在实时数据加载后仍要生效(loadLive 内部会把 index 归零)
722
+ if (state.initialIndex) state.index = state.initialIndex
643
723
  if (once) { draw(state); return }
644
724
  if (!process.stdout.isTTY) { process.stderr.write('请在有 TTY 的终端里运行;预览用 --once\n'); process.exitCode = 1; return }
645
725
 
@@ -695,6 +775,7 @@ async function main() {
695
775
  const ids = new Set(targets.map((c) => c.id))
696
776
  state.cards = state.cards.filter((c) => !ids.has(c.id))
697
777
  state.index = 0
778
+ state.listTop = 0
698
779
  state.marked.clear()
699
780
  toast(`${decision === 'a' ? '✓ 已批准' : '✕ 已驳回'} ${targets.length} 张:${targets[0].title}`, decision === 'a')
700
781
  }
@@ -725,6 +806,7 @@ async function main() {
725
806
  const i = ids.indexOf(state.screen === 'reader' ? state.readerBack : state.screen)
726
807
  state.screen = ids[(i + dir + ids.length) % ids.length]
727
808
  state.index = 0
809
+ state.listTop = 0
728
810
  draw(state)
729
811
  }
730
812
 
@@ -745,7 +827,7 @@ async function main() {
745
827
  if (key.name === 'tab') { cycle(key.shift ? -1 : 1); return }
746
828
  if (/^[1-6]$/.test(str)) {
747
829
  const id = ['library', 'audit', 'graph', 'status', 'recycle', 'settings'][Number(str) - 1]
748
- state.screen = id; state.index = 0; draw(state); return
830
+ state.screen = id; state.index = 0; state.listTop = 0; draw(state); return
749
831
  }
750
832
  if (state.screen === 'reader') {
751
833
  if (key.name === 'escape') { state.screen = state.readerBack; draw(state); return }
@@ -790,22 +872,28 @@ async function main() {
790
872
  if (str === 't' || key.name === 'right' || str === 'l') {
791
873
  const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
792
874
  state.kind = keys[(keys.indexOf(state.kind) + 1) % keys.length]
793
- state.index = 0; state.marked.clear(); draw(state); return
875
+ state.index = 0; state.listTop = 0; state.marked.clear(); draw(state); return
794
876
  }
795
877
  if (key.name === 'left' || str === 'h') {
796
878
  const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
797
879
  state.kind = keys[(keys.indexOf(state.kind) - 1 + keys.length) % keys.length]
798
- state.index = 0; state.marked.clear(); draw(state); return
880
+ state.index = 0; state.listTop = 0; state.marked.clear(); draw(state); return
799
881
  }
800
882
  if (state.screen === 'audit' && (str === 's' || (key.ctrl && key.name === 'a'))) {
801
883
  const all = cards.length > 0 && cards.every((c) => state.marked.has(c.id))
802
884
  cards.forEach((c) => all ? state.marked.delete(c.id) : state.marked.add(c.id))
803
885
  draw(state); return
804
886
  }
887
+ // 列表翻页:一次走一屏(state.listPage 由 libraryView / auditView 渲染时写入)
888
+ const pageStep = Math.max(1, state.listPage || 1)
889
+ if (key.name === 'pageup') { state.index = Math.max(0, state.index - pageStep); draw(state); return }
890
+ if (key.name === 'pagedown') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + pageStep); draw(state); return }
891
+ if (str === 'g') { state.index = 0; draw(state); return }
892
+ if (str === 'G') { state.index = Math.max(0, cards.length - 1); draw(state); return }
805
893
  if (key.name === 'up' || str === 'k') { state.index = Math.max(0, state.index - 1); draw(state); return }
806
894
  if (key.name === 'down' || str === 'j') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + 1); draw(state); return }
807
895
  if (key.name === 'return' || key.name === 'enter') { openReader(); return }
808
- if (key.name === 'escape') { state.query = ''; state.kind = 'all'; state.marked.clear(); state.index = 0; draw(state); return }
896
+ if (key.name === 'escape') { state.query = ''; state.kind = 'all'; state.marked.clear(); state.index = 0; state.listTop = 0; draw(state); return }
809
897
  if (state.screen === 'audit' && (key.name === 'space' || str === ' ')) {
810
898
  const c = cards[state.index]; if (c) { state.marked.has(c.id) ? state.marked.delete(c.id) : state.marked.add(c.id) } draw(state); return
811
899
  }