memory-eternal 0.11.0 → 0.13.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +22 -4
- package/README.md +22 -4
- package/cursor-extension/README.md +41 -0
- package/cursor-extension/extension.js +58 -0
- package/cursor-extension/icon.png +0 -0
- package/cursor-extension/package.json +54 -0
- package/dsh-tui-plugin/README.md +119 -0
- package/dsh-tui-plugin/cordis.patch.yml +3 -0
- package/dsh-tui-plugin/demo-audit.mjs +535 -0
- package/dsh-tui-plugin/demo.mjs +743 -0
- package/dsh-tui-plugin/dsh-plugin.json +42 -0
- package/dsh-tui-plugin/main.js +97 -0
- package/dsh-tui-plugin/package.json +31 -0
- package/dsh-tui-plugin/scene.js +151 -0
- package/lib/client.js +1 -1
- package/orca-plugin/README.md +58 -0
- package/orca-plugin/assets/icon.png +0 -0
- package/orca-plugin/orca-plugin.json +36 -0
- package/orca-plugin/panel.html +215 -0
- package/package.json +4 -1
- package/skills/memory-open/SKILL.md +22 -0
- package/web/app.js +1 -1
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Memory Atlas TUI —— 完整交互原型
|
|
3
|
+
// node dsh-tui-plugin/demo.mjs
|
|
4
|
+
// 静态帧:node dsh-tui-plugin/demo.mjs --once --no-color
|
|
5
|
+
// 全选预览:node dsh-tui-plugin/demo.mjs --once --no-color --select-all
|
|
6
|
+
//
|
|
7
|
+
// 示例数据,不连接真实 vault,不写任何卡片。
|
|
8
|
+
|
|
9
|
+
import readline from 'node:readline'
|
|
10
|
+
|
|
11
|
+
const C = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
ember: '\x1b[38;2;255;122;69m',
|
|
14
|
+
green: '\x1b[38;2;74;222;128m',
|
|
15
|
+
red: '\x1b[38;2;248;113;113m',
|
|
16
|
+
amber: '\x1b[38;2;251;191;36m',
|
|
17
|
+
blue: '\x1b[38;2;122;162;255m',
|
|
18
|
+
violet: '\x1b[38;2;183;156;255m',
|
|
19
|
+
cyan: '\x1b[38;2;103;232;249m',
|
|
20
|
+
pink: '\x1b[38;2;244;114;182m',
|
|
21
|
+
text: '\x1b[38;2;236;238;242m',
|
|
22
|
+
dim: '\x1b[38;2;126;135;146m',
|
|
23
|
+
faint: '\x1b[38;2;86;95;107m',
|
|
24
|
+
selectedBg: '\x1b[48;2;54;44;40m',
|
|
25
|
+
chipBg: '\x1b[48;2;66;44;37m',
|
|
26
|
+
keyBg: '\x1b[48;2;46;51;60m',
|
|
27
|
+
calloutBg: '\x1b[48;2;38;42;49m',
|
|
28
|
+
border: '\x1b[38;2;72;80;91m',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const KIND = {
|
|
32
|
+
project: { label: '项目', color: C.blue, mark: '◆' },
|
|
33
|
+
knowledge: { label: '知识', color: C.violet, mark: '◇' },
|
|
34
|
+
mistake: { label: '教训', color: C.red, mark: '▲' },
|
|
35
|
+
content: { label: '内容', color: C.pink, mark: '▣' },
|
|
36
|
+
tool: { label: '工具', color: C.cyan, mark: '⚙' },
|
|
37
|
+
prompt: { label: '提示词', color: C.amber, mark: '✦' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SCREENS = {
|
|
41
|
+
library: { label: '记忆库', num: 1 },
|
|
42
|
+
audit: { label: '待审', num: 2 },
|
|
43
|
+
graph: { label: '知识星图', num: 3 },
|
|
44
|
+
status: { label: '运行状态', num: 4 },
|
|
45
|
+
recycle: { label: '回收站', num: 5 },
|
|
46
|
+
settings: { label: '设置', num: 6 },
|
|
47
|
+
reader: { label: '读卡器', num: 0 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CARDS = [
|
|
51
|
+
{
|
|
52
|
+
id: 'memory-eternal-v5', kind: 'project', title: 'memory-eternal V5 收口:跨宿主入口',
|
|
53
|
+
source: 'deepseek-harness', time: '10:52', tags: ['memory-eternal', 'V5', 'UI'],
|
|
54
|
+
summary: '一套 Web UI 作为唯一真源,各宿主只做 Launcher;能内嵌的用 Webview,不能内嵌的只做启动器。',
|
|
55
|
+
body: [
|
|
56
|
+
'变更结论', '1. 记忆库改成 4 列卡片 + 今日回看', '2. 待审改成顶部类型条 + 右侧判词',
|
|
57
|
+
'3. 星图默认缩小,DPR/网格/边优化', '4. 读卡器补目录与相关记忆', '',
|
|
58
|
+
'边界', '· 不复制第二套 UI', '· 不新增 agent 审批权限', '· 不修改 DSH 官方源码',
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'mvcheck-camera', kind: 'knowledge', title: 'MVCheck 多相机独立成像参数',
|
|
63
|
+
source: 'deepseek-harness', time: '10:48', tags: ['MVCheck', '相机', '多视角'],
|
|
64
|
+
summary: '每路相机独立 SDK 实例,shared_sdk 降级为运行库保活;按序列号绑定,不再全局覆盖。',
|
|
65
|
+
body: [
|
|
66
|
+
'根因', '· 全局 shared_sdk 只有一个 handle', '· 打开第二台时覆盖第一台句柄',
|
|
67
|
+
'· 关闭只释放自己的句柄,真正资源仍被占用', '', '修复',
|
|
68
|
+
'· shared_sdk 降级为运行库保活', '· open/close 按序列号去重', '· 全局覆盖前检测占用并提示',
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'shj-port', kind: 'mistake', title: 'shj-web 端口菜单错位与 client 断连',
|
|
73
|
+
source: 'deepseek-harness', time: '10:43', tags: ['shj-platform', 'shj-web', '布局'],
|
|
74
|
+
summary: '端口菜单被侧栏删除后,client 仍等待旧端口事件;恢复菜单并补一条回归检查。',
|
|
75
|
+
body: [
|
|
76
|
+
'现象', '· client 一直显示连接中', '· 端口菜单入口消失', '', '处理',
|
|
77
|
+
'· 恢复「端口管理」菜单', '· 回归:打开菜单 → 连接 → 刷新',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'cursor-webview', kind: 'project', title: 'Cursor Webview 扩展:Memory Atlas',
|
|
82
|
+
source: 'codex', time: '10:38', tags: ['Cursor', 'Webview', '扩展'],
|
|
83
|
+
summary: 'Activity Bar 增加 Memory Atlas,用 Webview 加载同一套 7999 页面,版本与 DSH/Web 一致。',
|
|
84
|
+
body: [
|
|
85
|
+
'实现', '· contributes.viewsContainers.activitybar', '· contributes.views type=webview',
|
|
86
|
+
'· iframe 127.0.0.1:7999', '', '注意', '· Web 服务需要先运行', '· 不复制第二套前端',
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'orca-panel', kind: 'project', title: 'Orca 右侧栏记忆面板',
|
|
91
|
+
source: 'deepseek-harness', time: '10:31', tags: ['Orca', 'Panel', 'Launcher'],
|
|
92
|
+
summary: 'Orca 官方 panel + terminal.sendText,打开 Memory Atlas;CSP 禁止 iframe,所以是启动器。',
|
|
93
|
+
body: [
|
|
94
|
+
'能力', '· 右侧栏「记忆」', '· 选择终端', '· 执行 orca tab create / dsh-memory open',
|
|
95
|
+
'', '限制', '· panel 不能 iframe 127.0.0.1:7999', '· 不能 fetch 本地 API',
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'readme', kind: 'content', title: 'README 平台支持表',
|
|
100
|
+
source: 'deepseek-harness', time: '10:24', tags: ['README', '平台', '发布'],
|
|
101
|
+
summary: 'DSH / Orca / dsh-tui / Codex / Claude / Cursor / Web 一张表;能内嵌的 Webview,不能的 Launcher。',
|
|
102
|
+
body: [
|
|
103
|
+
'原则', '· 一套 Web UI 唯一真源', '· 版本统一', '· 不复制 UI', '',
|
|
104
|
+
'入口', '· DSH 左栏', '· Orca 右侧栏', '· dsh-tui /memory', '· Codex / Claude skill', '· Cursor Activity Bar',
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'longform', kind: 'content', title: '长文复盘:Memory Atlas 的一年',
|
|
109
|
+
source: 'deepseek-harness', time: '10:18', tags: ['长文', '复盘', 'V5'],
|
|
110
|
+
summary: '从一张知识卡到一个跨宿主记忆系统:这是一篇故意写长的读卡器样例,用来测试目录、换行和滚动。',
|
|
111
|
+
body: [
|
|
112
|
+
'背景',
|
|
113
|
+
'这个项目最开始并不叫 Memory Atlas。它只是 DSH 会话结束后的一段收尾动作:把值得留下的决定、踩过的坑和还没做完的事,写成一张卡片。',
|
|
114
|
+
'后来卡片越来越多,问题开始出现。不是“记不住”,而是“记得太多”。同一个决定有三种说法,同一个 bug 有三个版本,同一个方案在不同项目里重复出现。',
|
|
115
|
+
'于是我们做了一个现在看起来很自然的决定:把记忆当成一个需要被整理、被审核、被再次阅读的资产,而不是聊天记录旁边的一堆附件。',
|
|
116
|
+
'',
|
|
117
|
+
'变更结论',
|
|
118
|
+
'第一,卡片必须有类型。项目、知识、教训、内容、工具、提示词,不是装饰,而是检索和批驳时最基本的上下文。',
|
|
119
|
+
'第二,沉淀和审核必须分开。自动沉淀负责“先记下来”,审核负责“决定它是否值得长期保留”。这两件事如果混在一起,人就会在错误的时间做错误的判断。',
|
|
120
|
+
'第三,一套 Web UI 作为唯一真源。DSH 左栏、Orca 右侧栏、Cursor Webview、浏览器,都打开同一套界面。能内嵌的用 Webview,不能内嵌的只做启动器,不复制第二套前端。',
|
|
121
|
+
'第四,版本必须对齐。宿主、常驻服务、npm 包、Web 静态资源,任何一个版本漂移,用户看到的行为就会不一致。',
|
|
122
|
+
'',
|
|
123
|
+
'实现',
|
|
124
|
+
'记忆库使用卡片网格。每张卡展示类型、标题、摘要、标签和更新时间;今日回看放在最上面,用少量空间提醒“最近有什么值得再看一眼”。',
|
|
125
|
+
'待审页面使用顶部类型条 + 左侧队列 + 右侧判词。判词不是弹窗,因为弹窗会遮住上下文;右侧栏可以同时看到队列位置、卡片正文和批准/驳回按钮。',
|
|
126
|
+
'知识星图没有做复杂画布,而是先做连接簇和邻接表。对大多数用户来说,“这张卡和哪些卡有关”比“让我拖动一千个节点”更有用。',
|
|
127
|
+
'读卡器负责长内容。目录在右侧,相关记忆在下面,正文支持换行和滚动。一篇文章可以很长,但界面不能因此变成一堵墙。',
|
|
128
|
+
'',
|
|
129
|
+
'边界',
|
|
130
|
+
'· 不复制第二套 UI。Web 端是唯一实现,宿主只做容器或启动器。',
|
|
131
|
+
'· 不新增 agent 审批权限。批准和驳回必须由人完成。',
|
|
132
|
+
'· 不修改 DSH 官方源码。所有扩展都走插件和公开接口。',
|
|
133
|
+
'· 不为了“看起来完整”而牺牲可维护性。终端 UI 可以少一点效果,但不能多一套数据逻辑。',
|
|
134
|
+
'',
|
|
135
|
+
'限制',
|
|
136
|
+
'Orca 的 panel CSP 禁止 iframe 和 connect-src,所以右侧栏只能做启动器,完整 UI 仍在 Orca 内置浏览器或外部浏览器。',
|
|
137
|
+
'dsh-tui 没有 WebView,所以只能打开浏览器,或者做终端原生 TUI。终端原生不是 Web UI 的像素级复刻,而是同一套信息架构在字符网格里的重排。',
|
|
138
|
+
'Codex 和 Claude Code 的官方入口是 skill/command,没有通用侧栏;Cursor 的 Webview 才是真正可内嵌的宿主。',
|
|
139
|
+
'',
|
|
140
|
+
'原则',
|
|
141
|
+
'先让人看见,再让人决定。自动沉淀只负责让卡片出现,审核负责让卡片留下。',
|
|
142
|
+
'先做少而准的入口,再做多而全的适配。一个能用的启动器,比一个假的“内嵌”更诚实。',
|
|
143
|
+
'先统一数据,再统一外观。外观可以每个宿主不同,卡片、队列、审核结果必须相同。',
|
|
144
|
+
'',
|
|
145
|
+
'验证',
|
|
146
|
+
'长文读卡器要验证三件事:目录是否完整、正文是否换行、滚动是否跟手。这个样例故意超过一屏,用来暴露分页、截断和滚动边界的问题。',
|
|
147
|
+
'如果目录里的章节和正文对不上,或者滚动到底部时最后一行被吞掉,说明读卡器的视口计算还需要修。',
|
|
148
|
+
'',
|
|
149
|
+
'复盘',
|
|
150
|
+
'这一年最大的变化不是 UI 变漂亮了,而是我们终于把“记忆”当成一个完整生命周期:产生、沉淀、审核、阅读、关联、回收。',
|
|
151
|
+
'当这六个动作都能被看见,记忆库就不再是一个搜索结果页,而是一个可以反复回来的地方。',
|
|
152
|
+
'',
|
|
153
|
+
'下一步',
|
|
154
|
+
'把终端原型接进 dsh-tui 的真实场景,复用同一套审核守卫;把读卡器的滚动、目录和长文换行先打磨好,再谈更多宿主。',
|
|
155
|
+
'慢一点,但每一张卡都算数。',
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
const RECYCLE = [
|
|
160
|
+
{ title: '旧版 V5 布局草稿', kind: 'content', time: '昨天 18:20', reason: '过期方案' },
|
|
161
|
+
{ title: '端口菜单临时修复', kind: 'mistake', time: '10-20 09:12', reason: '已合并' },
|
|
162
|
+
{ title: 'Orca panel iframe 尝试', kind: 'project', time: '10-19 22:40', reason: 'CSP 阻塞' },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
const SETTINGS = [
|
|
166
|
+
{ label: '自动沉淀', detail: '对话结束后写入知识卡', value: '开' },
|
|
167
|
+
{ label: '失败提醒', detail: '沉淀失败时提示', value: '开' },
|
|
168
|
+
{ label: '审核后批准', detail: '批准即写入正式库', value: '开' },
|
|
169
|
+
{ label: '回收站保留', detail: '自动清理时间', value: '30 天' },
|
|
170
|
+
{ label: '主题', detail: 'Memory Atlas 配色', value: '余烬橙' },
|
|
171
|
+
{ label: '匿名统计', detail: '只统计运行状态', value: '关' },
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
const argv = new Set(process.argv.slice(2))
|
|
175
|
+
const once = argv.has('--once')
|
|
176
|
+
const selectAllAtStart = argv.has('--select-all')
|
|
177
|
+
const liveMode = argv.has('--live')
|
|
178
|
+
const writeMode = argv.has('--write')
|
|
179
|
+
const API = process.env.MEMORY_ETERNAL_API || 'http://127.0.0.1:7999/memory-eternal/api'
|
|
180
|
+
const screenArg = (process.argv.find((a) => a.startsWith('--screen=')) || '').split('=')[1]
|
|
181
|
+
const scrollArg = Number((process.argv.find((a) => a.startsWith('--scroll=')) || '').split('=')[1] || 0)
|
|
182
|
+
const useColor = !argv.has('--no-color') && !process.env.NO_COLOR
|
|
183
|
+
|
|
184
|
+
const sgr = (code, text) => (useColor ? code + text + C.reset : text)
|
|
185
|
+
const tone = (text, fg, bg = '') => (useColor ? `${fg || ''}${bg}${text}${C.reset}` : text)
|
|
186
|
+
const keycap = (key, label) => tone(` ${key} `, C.text, C.keyBg) + (label ? ' ' + sgr(C.dim, label) : '')
|
|
187
|
+
|
|
188
|
+
function charWidth(ch) {
|
|
189
|
+
const cp = ch.codePointAt(0)
|
|
190
|
+
if (cp === 0x200b || cp === 0xfe0f) return 0
|
|
191
|
+
if (cp >= 0x1100 && (cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
|
|
192
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || (cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
193
|
+
(cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
194
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
|
195
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff))) return 2
|
|
196
|
+
return 1
|
|
197
|
+
}
|
|
198
|
+
const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '')
|
|
199
|
+
const width = (s) => [...plain(s)].reduce((n, ch) => n + charWidth(ch), 0)
|
|
200
|
+
|
|
201
|
+
function clip(s, max) {
|
|
202
|
+
let out = '', n = 0
|
|
203
|
+
for (let i = 0; i < s.length;) {
|
|
204
|
+
if (s[i] === '\x1b') {
|
|
205
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i))
|
|
206
|
+
if (m) { out += m[0]; i += m[0].length; continue }
|
|
207
|
+
}
|
|
208
|
+
const ch = String.fromCodePoint(s.codePointAt(i))
|
|
209
|
+
const w = charWidth(ch)
|
|
210
|
+
if (n + w > max) break
|
|
211
|
+
out += ch; n += w; i += ch.length
|
|
212
|
+
}
|
|
213
|
+
return out + (useColor && out.includes('\x1b[') ? C.reset : '')
|
|
214
|
+
}
|
|
215
|
+
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))
|
|
216
|
+
const wrap = (text, n) => {
|
|
217
|
+
const lines = []; let line = '', used = 0
|
|
218
|
+
for (const ch of String(text)) {
|
|
219
|
+
const w = charWidth(ch)
|
|
220
|
+
if (used + w > n) { lines.push(line); line = ''; used = 0 }
|
|
221
|
+
line += ch; used += w
|
|
222
|
+
}
|
|
223
|
+
if (line) lines.push(line)
|
|
224
|
+
return lines.length ? lines : ['']
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function panel(title, lines, w, h, accent = C.border) {
|
|
228
|
+
const inner = Math.max(1, w - 2)
|
|
229
|
+
const t = ' ' + title + ' '
|
|
230
|
+
const top = sgr(accent, '╭─') + sgr(accent, t) + sgr(accent, '─'.repeat(Math.max(0, inner - width(t))) + '╮')
|
|
231
|
+
const bottom = sgr(accent, '╰' + '─'.repeat(inner) + '╯')
|
|
232
|
+
const body = []
|
|
233
|
+
for (let i = 0; i < h - 2; i++) body.push(sgr(C.border, '│') + pad(clip(lines[i] || '', inner), inner) + sgr(C.border, '│'))
|
|
234
|
+
return [top, ...body, bottom].join('\n')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const kindOf = (c) => KIND[c.kind] || KIND.project
|
|
238
|
+
const filtered = (state) => {
|
|
239
|
+
const q = state.query.trim().toLowerCase()
|
|
240
|
+
return state.cards.filter((c) => {
|
|
241
|
+
if (state.kind !== 'all' && c.kind !== state.kind) return false
|
|
242
|
+
if (!q) return true
|
|
243
|
+
return (c.title + ' ' + c.summary + ' ' + c.tags.join(' ')).toLowerCase().includes(q)
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function typeRow(state, w) {
|
|
248
|
+
const labels = [['全部', 'all'], ['项目', 'project'], ['知识', 'knowledge'], ['教训', 'mistake'], ['内容', 'content'], ['工具', 'tool'], ['提示词', 'prompt']]
|
|
249
|
+
const line = sgr(C.faint, '类型 ') + labels.map(([label, key]) => {
|
|
250
|
+
const active = state.kind === key
|
|
251
|
+
const count = key === 'all' ? state.cards.length : state.cards.filter((c) => c.kind === key).length
|
|
252
|
+
return tone(` ${active ? '●' : '○'} ${label} ${count} `, active ? C.text : C.dim, active ? C.chipBg : '')
|
|
253
|
+
}).join(' ')
|
|
254
|
+
return ' ' + clip(line + ' ' + sgr(C.faint, '←→ 类型'), w - 1)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function searchRow(state, w) {
|
|
258
|
+
const text = state.searchMode ? state.query + '▌' : (state.query || '搜索记忆、标签…')
|
|
259
|
+
return ' ' + sgr(C.faint, '/ ') + sgr(state.searchMode ? C.text : C.faint, text) + ' ' + sgr(C.faint, 'Enter 确认 · Esc 清空')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function queueLines(cards, state, w) {
|
|
263
|
+
if (!cards.length) return ['', sgr(C.green, ' ✓ 没有匹配的卡片'), '', sgr(C.dim, ' 按 Esc 清空搜索 / 筛选')]
|
|
264
|
+
const lines = []
|
|
265
|
+
const chosen = cards.filter((c) => state.marked.has(c.id)).length
|
|
266
|
+
const all = cards.length > 0 && chosen === cards.length
|
|
267
|
+
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 ? '反选' : '全选')}`)
|
|
268
|
+
lines.push('')
|
|
269
|
+
cards.forEach((card, i) => {
|
|
270
|
+
const k = kindOf(card)
|
|
271
|
+
const selected = i === state.index
|
|
272
|
+
const marked = state.marked.has(card.id)
|
|
273
|
+
const marker = marked ? sgr(C.amber, '◉') : sgr(C.faint, '○')
|
|
274
|
+
const lead = selected ? sgr(C.ember, '▌') : ' '
|
|
275
|
+
let line = `${lead}${marker} ${tone(' ' + k.mark + ' ', k.color, selected ? C.selectedBg : '')} ${sgr(k.color, '[' + k.label + ']')} ${sgr(C.text, card.title)}`
|
|
276
|
+
line = clip(line, w)
|
|
277
|
+
if (selected) line = tone(pad(line, w), C.text, C.selectedBg)
|
|
278
|
+
lines.push(line)
|
|
279
|
+
let meta = ` ${sgr(C.faint, card.source)} ${sgr(C.faint, '·')} ${sgr(C.dim, card.time)} ${sgr(C.amber, '待审')}`
|
|
280
|
+
if (selected) meta = tone(pad(clip(meta, w), w), '', C.selectedBg)
|
|
281
|
+
lines.push(clip(meta, w))
|
|
282
|
+
lines.push('')
|
|
283
|
+
})
|
|
284
|
+
return lines
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function previewLines(card, w) {
|
|
288
|
+
if (!card) return [sgr(C.dim, '没有可预览的卡片')]
|
|
289
|
+
const k = kindOf(card)
|
|
290
|
+
const inner = Math.max(8, w - 2)
|
|
291
|
+
const lines = [
|
|
292
|
+
tone(' ' + k.mark + ' ' + k.label + ' ', k.color, C.chipBg) + tone(' 待审核 ', C.amber, C.chipBg) + sgr(C.faint, ' ' + card.time + ' ' + card.source),
|
|
293
|
+
'', sgr(C.text, card.title), '',
|
|
294
|
+
tone(' 判词 ', C.ember, C.chipBg) + ' ' + sgr(C.dim, '原文') + ' ' + sgr(C.dim, '相关'), '',
|
|
295
|
+
]
|
|
296
|
+
for (const part of wrap('摘要 ' + card.summary, inner)) lines.push(tone(part, C.text, C.calloutBg))
|
|
297
|
+
lines.push('', card.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' '), '', sgr(C.border, '─'.repeat(inner)), '')
|
|
298
|
+
const headings = /^(变更结论|根因|现象|处理|修复|边界|实现|限制|能力|原则|入口|注意|背景|验证)$/
|
|
299
|
+
for (const line of card.body) {
|
|
300
|
+
if (!line) { lines.push(''); continue }
|
|
301
|
+
lines.push(headings.test(line) ? sgr('\x1b[1m' + C.ember, line) : line.startsWith('·') ? sgr(C.dim, line) : sgr(C.text, line))
|
|
302
|
+
}
|
|
303
|
+
const related = CARDS.filter((c) => c.id !== card.id).slice(0, 2)
|
|
304
|
+
if (related.length) {
|
|
305
|
+
lines.push('', sgr(C.dim, '相关记忆'))
|
|
306
|
+
for (const r of related) lines.push(`${sgr(kindOf(r).color, '→')} ${sgr(C.dim, r.title)}`)
|
|
307
|
+
}
|
|
308
|
+
lines.push('', [keycap('a', '批准'), keycap('r', '驳回'), keycap('Space', '多选')].join(' '))
|
|
309
|
+
return lines
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function auditView(state, w, h) {
|
|
313
|
+
const cards = filtered(state)
|
|
314
|
+
const selected = cards[state.index]
|
|
315
|
+
const qw = Math.max(40, Math.floor((w - 1) * 0.4))
|
|
316
|
+
const pw = w - qw - 1
|
|
317
|
+
const ph = h - 2
|
|
318
|
+
const q = panel(`队列 ${cards.length}${state.marked.size ? ' ◉' + state.marked.size : ''}`, queueLines(cards, state, qw - 2), qw, ph)
|
|
319
|
+
const p = panel('判词', previewLines(selected, pw - 2), pw, ph, C.ember)
|
|
320
|
+
const ql = q.split('\n'), pl = p.split('\n')
|
|
321
|
+
const out = [typeRow(state, w), '']
|
|
322
|
+
for (let i = 0; i < Math.max(ql.length, pl.length); i++) out.push((ql[i] || ' '.repeat(qw)) + ' ' + (pl[i] || ' '.repeat(pw)))
|
|
323
|
+
return out.slice(0, h)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function cardRow(card, selected, w, compact) {
|
|
327
|
+
const k = kindOf(card)
|
|
328
|
+
const lead = selected ? sgr(C.ember, '▌') : ' '
|
|
329
|
+
let line = `${lead} ${tone(' ' + k.mark + ' ', k.color, selected ? C.selectedBg : '')} ${sgr(k.color, '[' + k.label + ']')} ${sgr(C.text, card.title)}`
|
|
330
|
+
if (selected) line = tone(pad(clip(line, w), w), C.text, C.selectedBg)
|
|
331
|
+
const meta = ` ${sgr(C.faint, card.source)} ${sgr(C.faint, '·')} ${sgr(C.dim, card.time)} ${card.tags.slice(0, 3).map((t) => sgr(C.ember, '#' + t)).join(' ')}`
|
|
332
|
+
return [clip(line, w), selected ? tone(pad(clip(meta, w), w), '', C.selectedBg) : clip(meta, w)]
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function libraryView(state, w, h) {
|
|
336
|
+
const cards = filtered(state)
|
|
337
|
+
const lines = [typeRow(state, w), '', searchRow(state, w), '']
|
|
338
|
+
const feature = cards[0] || CARDS[0]
|
|
339
|
+
lines.push(`${sgr(C.ember, '✦ 今日回看')} ${sgr(C.text, feature.title)}`)
|
|
340
|
+
lines.push(` ${sgr(C.dim, feature.summary)}`)
|
|
341
|
+
lines.push(` ${feature.tags.map((t) => tone(' #' + t + ' ', C.ember, C.chipBg)).join(' ')}`)
|
|
342
|
+
lines.push('', sgr(C.border, '─'.repeat(Math.max(1, w - 2))), '')
|
|
343
|
+
lines.push(`${sgr(C.dim, '全部记忆')} ${sgr(C.faint, cards.length + ' 张 · 按 Enter 读卡')}`)
|
|
344
|
+
lines.push('')
|
|
345
|
+
cards.forEach((card, i) => {
|
|
346
|
+
lines.push(...cardRow(card, i === state.index, w - 2, true))
|
|
347
|
+
lines.push('')
|
|
348
|
+
})
|
|
349
|
+
return [panel('记忆库', lines, w, h, C.border)]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function readerView(state, w, h) {
|
|
353
|
+
const card = state.readerCard || CARDS.find((c) => c.id === 'longform') || CARDS[0]
|
|
354
|
+
const k = kindOf(card)
|
|
355
|
+
const left = Math.max(40, Math.floor((w - 1) * 0.66))
|
|
356
|
+
const right = w - left - 1
|
|
357
|
+
const inner = Math.max(12, left - 4)
|
|
358
|
+
const all = [
|
|
359
|
+
tone(' ' + k.mark + ' ' + k.label + ' ', k.color, C.chipBg) + tone(' 长文 ', C.amber, C.chipBg) + sgr(C.faint, ' ' + card.time + ' ' + card.source),
|
|
360
|
+
'', sgr(C.text, card.title), '',
|
|
361
|
+
...wrap(card.summary, inner).map((l) => sgr(C.dim, l)),
|
|
362
|
+
'', sgr(C.border, '─'.repeat(inner)), '',
|
|
363
|
+
]
|
|
364
|
+
const headings = /^(背景|变更结论|实现|边界|限制|原则|验证|复盘|下一步|根因|现象|处理|修复|能力|入口|注意)$/
|
|
365
|
+
for (const line of card.body) {
|
|
366
|
+
if (!line) { all.push(''); continue }
|
|
367
|
+
if (headings.test(line)) {
|
|
368
|
+
all.push('', sgr('\x1b[1m' + C.ember, line), '')
|
|
369
|
+
} else if (line.startsWith('·')) {
|
|
370
|
+
all.push(...wrap(line, inner).map((l) => sgr(C.dim, l)))
|
|
371
|
+
} else {
|
|
372
|
+
all.push(...wrap(line, inner).map((l) => sgr(C.text, l)))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const page = Math.max(4, h - 2)
|
|
376
|
+
const maxScroll = Math.max(0, all.length - page)
|
|
377
|
+
const scroll = Math.min(Math.max(0, state.readerScroll || 0), maxScroll)
|
|
378
|
+
state.readerScroll = scroll
|
|
379
|
+
state.readerMax = maxScroll
|
|
380
|
+
const toc = [sgr(C.dim, '目录'), '']
|
|
381
|
+
const heads = card.body.filter((l) => headings.test(l))
|
|
382
|
+
heads.forEach((head, i) => toc.push(`${sgr(C.faint, String(i + 1).padStart(2, '0'))} ${sgr(C.text, head)}`))
|
|
383
|
+
toc.push('', sgr(C.dim, '相关记忆'))
|
|
384
|
+
CARDS.filter((c) => c.id !== card.id).slice(0, 3).forEach((r) => toc.push(`${sgr(kindOf(r).color, '→')} ${sgr(C.dim, r.title)}`))
|
|
385
|
+
toc.push('', sgr(C.faint, `第 ${scroll + 1}-${Math.min(all.length, scroll + page)} 行 / 共 ${all.length} 行`))
|
|
386
|
+
const l = panel(`读卡器 · ${scroll + 1}-${Math.min(all.length, scroll + page)}/${all.length}`, all.slice(scroll, scroll + page), left, h, C.ember)
|
|
387
|
+
const r = panel('目录 / 相关', toc, right, h)
|
|
388
|
+
const ll = l.split('\n'), rl = r.split('\n')
|
|
389
|
+
const out = []
|
|
390
|
+
for (let i = 0; i < Math.max(ll.length, rl.length); i++) out.push((ll[i] || ' '.repeat(left)) + ' ' + (rl[i] || ' '.repeat(right)))
|
|
391
|
+
return out.slice(0, h)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function graphView(state, w, h) {
|
|
395
|
+
const cluster = [['项目', 417, C.blue], ['知识', 219, C.violet], ['内容', 112, C.pink], ['教训', 185, C.red], ['工具', 51, C.cyan], ['提示词', 12, C.amber]]
|
|
396
|
+
const card = filtered(state)[state.index] || CARDS[0]
|
|
397
|
+
const lines = [sgr(C.ember, '知识星图'), `${sgr(C.dim, '999 节点 · 4713 连线')}`, '', sgr(C.dim, '连接簇')]
|
|
398
|
+
cluster.forEach(([label, n, color]) => lines.push(` ${sgr(color, '●')} ${sgr(C.text, label).padEnd(8)} ${sgr(C.dim, String(n))}`))
|
|
399
|
+
lines.push('', sgr(C.border, '─'.repeat(Math.max(1, w - 2))), '', `${sgr(C.dim, '选中')} ${sgr(C.text, card.title)}`, ` ${sgr(C.dim, '↔ 12 张相连')} ${card.tags.map((t) => sgr(C.ember, '#' + t)).join(' ')}`, '', sgr(C.dim, '相关卡片'))
|
|
400
|
+
CARDS.filter((c) => c.id !== card.id).slice(0, 4).forEach((r) => lines.push(`${sgr(kindOf(r).color, '→')} ${sgr(C.dim, r.title)}`))
|
|
401
|
+
return [panel('知识星图', lines, w, h, C.violet)]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function statusView(state, w, h) {
|
|
405
|
+
const lines = [
|
|
406
|
+
`${sgr(C.ember, '●')} ${sgr(C.text, '记忆库')} ${sgr(C.amber, '999')} ${sgr(C.dim, '备份')} ${sgr(C.text, '7')} ${sgr(C.dim, '版本')} ${sgr(C.text, '0.12.0')}`,
|
|
407
|
+
`${sgr(C.green, '●')} ${sgr(C.text, '自动沉淀')} ${sgr(C.green, '正常')} ${sgr(C.dim, '上次')} ${sgr(C.text, '18:20')} ${sgr(C.dim, '队列')} ${sgr(C.text, '0')}`,
|
|
408
|
+
'', sgr(C.border, '─'.repeat(Math.max(1, w - 2))), '', sgr(C.dim, '沉淀日志'),
|
|
409
|
+
`${sgr(C.faint, '18:20')} ${sgr(C.green, 'created')} 新卡:Orca 右侧栏启动器`,
|
|
410
|
+
`${sgr(C.faint, '18:18')} ${sgr(C.blue, 'listen')} 会话事件 32 条 → 有效 1199 字`,
|
|
411
|
+
`${sgr(C.faint, '18:16')} ${sgr(C.faint, 'skip')} 低信号会话,未生成卡片`,
|
|
412
|
+
'', sgr(C.dim, '审核'), `${sgr(C.amber, '6')} 张待审 ${sgr(C.dim, '回收站')} 3 张 ${sgr(C.dim, '常驻')} 7999`,
|
|
413
|
+
]
|
|
414
|
+
return [panel('运行状态', lines, w, h, C.green)]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function recycleView(state, w, h) {
|
|
418
|
+
const lines = [sgr(C.dim, '回收站里的卡片不会进入检索;恢复后回到记忆库。'), '']
|
|
419
|
+
if (!state.recycle.length) lines.push(sgr(C.green, ' ✓ 回收站为空'))
|
|
420
|
+
state.recycle.forEach((item, i) => {
|
|
421
|
+
const k = kindOf(item)
|
|
422
|
+
const sel = i === state.recycleIndex
|
|
423
|
+
let line = `${sel ? sgr(C.ember, '▌') : ' '} ${sgr(k.color, '[' + k.label + ']')} ${sgr(C.text, item.title)}`
|
|
424
|
+
if (sel) line = tone(pad(clip(line, w - 2), w - 2), C.text, C.selectedBg)
|
|
425
|
+
lines.push(line)
|
|
426
|
+
lines.push(` ${sgr(C.faint, item.time)} ${sgr(C.dim, item.reason)}`)
|
|
427
|
+
lines.push('')
|
|
428
|
+
})
|
|
429
|
+
lines.push(sgr(C.dim, 'r 恢复 d 彻底删除'))
|
|
430
|
+
return [panel('回收站', lines, w, h, C.red)]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function settingsView(state, w, h) {
|
|
434
|
+
const lines = [sgr(C.dim, '这些是示例开关,只改当前演示,不写配置。'), '']
|
|
435
|
+
state.settings.forEach((s, i) => {
|
|
436
|
+
const sel = i === state.settingsIndex
|
|
437
|
+
const on = s.value === '开' || s.value === '余烬橙'
|
|
438
|
+
let line = `${sel ? sgr(C.ember, '▌') : ' '} ${sgr(on ? C.green : C.faint, on ? '●' : '○')} ${sgr(C.text, s.label.padEnd(12))} ${sgr(C.amber, s.value)}`
|
|
439
|
+
if (sel) line = tone(pad(clip(line, w - 2), w - 2), C.text, C.selectedBg)
|
|
440
|
+
lines.push(line)
|
|
441
|
+
lines.push(` ${sgr(C.dim, s.detail)}`)
|
|
442
|
+
lines.push('')
|
|
443
|
+
})
|
|
444
|
+
lines.push(sgr(C.dim, 'Enter / Space 切换'))
|
|
445
|
+
return [panel('设置', lines, w, h, C.amber)]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function helpView(w, h) {
|
|
449
|
+
const lines = [
|
|
450
|
+
sgr(C.ember, '键盘'), '',
|
|
451
|
+
` ${keycap('1')}–${keycap('6')} 记忆库 / 待审 / 星图 / 状态 / 回收 / 设置`,
|
|
452
|
+
` ${keycap('Tab')} 切换页面`,
|
|
453
|
+
` ${keycap('↑↓')} ${keycap('jk')} 移动`,
|
|
454
|
+
` ${keycap('←→')} ${keycap('t')} 切换类型`,
|
|
455
|
+
` ${keycap('Enter')} 读卡 / 切换设置`,
|
|
456
|
+
` ${keycap('Space')} 多选 / 切换设置`,
|
|
457
|
+
` ${keycap('s')} ${keycap('Ctrl+A')} 全选 / 反选`,
|
|
458
|
+
` ${keycap('a')} ${keycap('r')} 批准 / 驳回`,
|
|
459
|
+
` ${keycap('r')} ${keycap('d')} 回收站:恢复 / 彻底删除`,
|
|
460
|
+
` ${keycap('/')} 搜索`,
|
|
461
|
+
` ${keycap('L')} 打开长文读卡器`,
|
|
462
|
+
` ${keycap('j k')} 读卡器滚动(PgUp/PgDn 翻页)`,
|
|
463
|
+
` ${keycap('Esc')} 返回 / 清空选择`,
|
|
464
|
+
` ${keycap('?')} ${keycap('q')} 帮助 / 退出`,
|
|
465
|
+
'', sgr(C.dim, '示例数据,不会写入真实 vault。'),
|
|
466
|
+
]
|
|
467
|
+
return [panel('帮助', lines, w, h, C.ember)]
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function footer(state, w) {
|
|
471
|
+
if (state.searchMode) return ' ' + [keycap('Enter', '确认'), keycap('Esc', '取消')].join(' ')
|
|
472
|
+
if (state.help) return ' ' + [keycap('Esc', '关闭帮助'), keycap('?', '关闭')].join(' ')
|
|
473
|
+
if (state.screen === 'reader') return ' ' + [keycap('↑↓', '滚动'), keycap('PgUp/PgDn', '翻页'), keycap('g/G', '首/尾'), keycap('Esc', '返回')].join(' ')
|
|
474
|
+
if (state.screen === 'settings') return ' ' + [keycap('↑↓', '移动'), keycap('Enter', '切换'), keycap('Space', '切换'), keycap('Esc', '返回')].join(' ')
|
|
475
|
+
if (state.screen === 'recycle') return ' ' + [keycap('↑↓', '移动'), keycap('r', '恢复'), keycap('d', '删除')].join(' ')
|
|
476
|
+
if (state.screen === 'audit' && state.marked.size) return ' ' + [keycap('a', '批准 ' + state.marked.size), keycap('r', '驳回'), keycap('s', '反选'), keycap('Esc', '清空')].join(' ')
|
|
477
|
+
if (state.screen === 'audit') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('Space', '多选'), keycap('s', '全选'), keycap('a', '批准'), keycap('r', '驳回'), keycap('Enter', '读卡'), keycap('1-6', '页面'), keycap('?', '帮助')].join(' ')
|
|
478
|
+
if (state.screen === 'library') return ' ' + [keycap('↑↓', '移动'), keycap('←→', '类型'), keycap('/', '搜索'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
|
|
479
|
+
if (state.screen === 'graph') return ' ' + [keycap('↑↓', '切换卡片'), keycap('Enter', '读卡'), keycap('1-6', '页面')].join(' ')
|
|
480
|
+
if (state.screen === 'status') return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
|
|
481
|
+
return ' ' + [keycap('1-6', '页面'), keycap('Tab', '切换'), keycap('?', '帮助')].join(' ')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function frame(state) {
|
|
485
|
+
const w = Math.max(72, Math.min(useColor ? 140 : 120, process.stdout.columns || 120))
|
|
486
|
+
const rows = Math.max(24, process.stdout.rows || 36)
|
|
487
|
+
const screen = state.help ? 'help' : state.screen
|
|
488
|
+
const label = screen === 'help' ? '帮助' : SCREENS[screen]?.label || '记忆库'
|
|
489
|
+
const counts = `${sgr(C.amber, String(state.cards.length))} ${sgr(C.dim, '待审')}` + (state.marked.size ? ` ${sgr(C.amber, '已选 ' + state.marked.size)}` : '')
|
|
490
|
+
const mode = state.live ? (writeMode ? 'LIVE / WRITE' : 'LIVE / READONLY') : 'LOCAL MEMORY'
|
|
491
|
+
const title = `${sgr(C.ember, '●')} ${sgr(C.ember, 'MEMORY ATLAS')} ${sgr(C.faint, mode)} ${sgr(C.faint, '/')} ${sgr(C.text, label)}`
|
|
492
|
+
const right = `${counts} ${keycap('?', '帮助')} ${keycap('q', '退出')}`
|
|
493
|
+
const room = Math.max(16, w - width(right) - 2)
|
|
494
|
+
const out = [pad(clip(title, room), room) + ' ' + right, sgr(C.border, '─'.repeat(w))]
|
|
495
|
+
const nav = Object.entries(SCREENS).filter(([id]) => id !== 'reader').map(([id, s]) => {
|
|
496
|
+
const active = state.screen === id
|
|
497
|
+
return tone(` ${s.num} ${s.label}${id === 'audit' ? ' ' + state.cards.length : ''} `, active ? C.text : C.dim, active ? C.chipBg : '')
|
|
498
|
+
}).join(' ')
|
|
499
|
+
out.push(' ' + clip(nav, w - 1))
|
|
500
|
+
const bodyH = Math.max(12, rows - 5)
|
|
501
|
+
let body
|
|
502
|
+
if (state.help) body = helpView(w, bodyH)
|
|
503
|
+
else if (state.screen === 'audit') body = auditView(state, w, bodyH)
|
|
504
|
+
else if (state.screen === 'library') body = libraryView(state, w, bodyH)
|
|
505
|
+
else if (state.screen === 'reader') body = readerView(state, w, bodyH)
|
|
506
|
+
else if (state.screen === 'graph') body = graphView(state, w, bodyH)
|
|
507
|
+
else if (state.screen === 'status') body = statusView(state, w, bodyH)
|
|
508
|
+
else if (state.screen === 'recycle') body = recycleView(state, w, bodyH)
|
|
509
|
+
else body = settingsView(state, w, bodyH)
|
|
510
|
+
out.push(...body.slice(0, bodyH))
|
|
511
|
+
const line = state.toast ? sgr(state.toast.ok ? C.green : C.red, state.toast.text) : footer(state, w).trimStart()
|
|
512
|
+
out.push(' ' + clip(line, w - 1))
|
|
513
|
+
return out.join('\n')
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function draw(state) {
|
|
517
|
+
if (once) { process.stdout.write(frame(state) + '\n'); return }
|
|
518
|
+
process.stdout.write('\x1b[H' + frame(state) + '\x1b[J')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function apiJson(path, options = {}) {
|
|
522
|
+
const res = await fetch(API + path, { ...options, signal: AbortSignal.timeout(10000) })
|
|
523
|
+
const data = await res.json().catch(() => null)
|
|
524
|
+
if (!res.ok || !data || data.ok === false) throw new Error((data && data.error) || `HTTP ${res.status}`)
|
|
525
|
+
return data
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function fmtTime(value) {
|
|
529
|
+
const d = new Date(value || Date.now())
|
|
530
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
531
|
+
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function cleanSummary(value) {
|
|
535
|
+
return String(value || '').replace(/^---[\s\S]*?---\s*/, '').replace(/^#{1,6}\s*/gm, '').replace(/\s+/g, ' ').trim().slice(0, 220)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function toLiveCard(c) {
|
|
539
|
+
return {
|
|
540
|
+
id: c.path,
|
|
541
|
+
kind: c.kind || 'knowledge',
|
|
542
|
+
title: c.title || '无标题',
|
|
543
|
+
source: c.submittedBy || c.source || 'memory',
|
|
544
|
+
time: fmtTime(c.updated || c.created),
|
|
545
|
+
tags: Array.isArray(c.tags) ? c.tags.slice(0, 5) : [],
|
|
546
|
+
summary: cleanSummary(c.summary || ''),
|
|
547
|
+
body: [],
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parseCardBody(text) {
|
|
552
|
+
const clean = String(text || '').replace(/^---[\s\S]*?---\s*/, '')
|
|
553
|
+
return clean.split(/\r?\n/).map((line) => line.replace(/^#{1,6}\s*/, '').replace(/^\s*[-*]\s+/, '· ').trimEnd())
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function loadLive(state) {
|
|
557
|
+
try {
|
|
558
|
+
const [cardsData, overview] = await Promise.all([
|
|
559
|
+
apiJson('/cards?status=pending&limit=300'),
|
|
560
|
+
apiJson('/overview').catch(() => null),
|
|
561
|
+
])
|
|
562
|
+
if (!cardsData.cards.length) throw new Error('没有待审卡')
|
|
563
|
+
state.cards = cardsData.cards.map(toLiveCard)
|
|
564
|
+
state.overview = overview
|
|
565
|
+
state.index = 0
|
|
566
|
+
state.marked.clear()
|
|
567
|
+
if (selectAllAtStart) state.cards.forEach((c) => state.marked.add(c.id))
|
|
568
|
+
state.live = true
|
|
569
|
+
state.liveError = ''
|
|
570
|
+
} catch (err) {
|
|
571
|
+
state.live = false
|
|
572
|
+
state.liveError = err.message
|
|
573
|
+
state.toast = { text: '实时 API 不可用,已回退示例数据:' + err.message, ok: false }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
async function main() {
|
|
579
|
+
const state = {
|
|
580
|
+
screen: SCREENS[screenArg] ? screenArg : 'audit', cards: CARDS.slice(), index: 0, kind: 'all', query: '', searchMode: false,
|
|
581
|
+
marked: selectAllAtStart ? new Set(CARDS.map((c) => c.id)) : new Set(),
|
|
582
|
+
readerCard: screenArg === 'reader' ? CARDS.find((c) => c.id === 'longform') || CARDS[0] : null,
|
|
583
|
+
readerBack: 'audit', readerScroll: scrollArg > 0 ? scrollArg : 0, readerMax: 0, recycle: RECYCLE.slice(), recycleIndex: 0,
|
|
584
|
+
settings: SETTINGS.map((s) => ({ ...s })), settingsIndex: 0, help: screenArg === 'help', toast: null,
|
|
585
|
+
live: false, liveError: '', overview: null,
|
|
586
|
+
}
|
|
587
|
+
if (liveMode) await loadLive(state)
|
|
588
|
+
if (once) { draw(state); return }
|
|
589
|
+
if (!process.stdout.isTTY) { process.stderr.write('请在有 TTY 的终端里运行;预览用 --once\n'); process.exitCode = 1; return }
|
|
590
|
+
|
|
591
|
+
readline.emitKeypressEvents(process.stdin)
|
|
592
|
+
process.stdin.setRawMode(true)
|
|
593
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l')
|
|
594
|
+
draw(state)
|
|
595
|
+
const cleanup = () => {
|
|
596
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l')
|
|
597
|
+
try { process.stdin.setRawMode(false) } catch {}
|
|
598
|
+
process.stdin.pause()
|
|
599
|
+
}
|
|
600
|
+
const toast = (text, ok = true) => { state.toast = { text, ok }; draw(state); setTimeout(() => { state.toast = null; draw(state) }, 1600) }
|
|
601
|
+
const visible = () => filtered(state)
|
|
602
|
+
const selectedCard = () => visible()[state.index]
|
|
603
|
+
|
|
604
|
+
const removeSelected = async (decision) => {
|
|
605
|
+
const cards = visible()
|
|
606
|
+
const targets = state.marked.size ? cards.filter((c) => state.marked.has(c.id)) : (cards[state.index] ? [cards[state.index]] : [])
|
|
607
|
+
if (!targets.length) return
|
|
608
|
+
if (state.live && !writeMode) {
|
|
609
|
+
toast('实时模式只读;加 --write 才允许批准/驳回')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
if (state.live) {
|
|
613
|
+
try {
|
|
614
|
+
await Promise.all(targets.map((c) => apiJson('/audit/' + (decision === 'a' ? 'approve' : 'reject'), {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: { 'Content-Type': 'application/json' },
|
|
617
|
+
body: JSON.stringify({ path: c.id }),
|
|
618
|
+
})))
|
|
619
|
+
} catch (err) {
|
|
620
|
+
toast('操作失败:' + err.message, false)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const ids = new Set(targets.map((c) => c.id))
|
|
625
|
+
state.cards = state.cards.filter((c) => !ids.has(c.id))
|
|
626
|
+
state.index = 0
|
|
627
|
+
state.marked.clear()
|
|
628
|
+
toast(`${decision === 'a' ? '✓ 已批准' : '✕ 已驳回'} ${targets.length} 张:${targets[0].title}`, decision === 'a')
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const openReader = async () => {
|
|
632
|
+
const card = selectedCard()
|
|
633
|
+
if (!card) return
|
|
634
|
+
state.readerCard = card
|
|
635
|
+
state.readerBack = state.screen
|
|
636
|
+
state.readerScroll = 0
|
|
637
|
+
state.screen = 'reader'
|
|
638
|
+
if (state.live && !card.body.length) {
|
|
639
|
+
card.body = ['加载正文…']
|
|
640
|
+
draw(state)
|
|
641
|
+
try {
|
|
642
|
+
const data = await apiJson('/card?path=' + encodeURIComponent(card.id) + '&status=pending')
|
|
643
|
+
card.body = parseCardBody(data.text)
|
|
644
|
+
} catch (err) {
|
|
645
|
+
card.body = ['正文加载失败:' + err.message]
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
draw(state)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const cycle = (dir) => {
|
|
652
|
+
const ids = Object.keys(SCREENS).filter((id) => id !== 'reader')
|
|
653
|
+
const i = ids.indexOf(state.screen === 'reader' ? state.readerBack : state.screen)
|
|
654
|
+
state.screen = ids[(i + dir + ids.length) % ids.length]
|
|
655
|
+
state.index = 0
|
|
656
|
+
draw(state)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const onKey = (str, key = {}) => {
|
|
660
|
+
if (key.ctrl && key.name === 'c') { cleanup(); process.exit(0) }
|
|
661
|
+
if (state.searchMode) {
|
|
662
|
+
if (key.name === 'escape') { state.searchMode = false; state.query = ''; draw(state); return }
|
|
663
|
+
if (key.name === 'return' || key.name === 'enter') { state.searchMode = false; draw(state); return }
|
|
664
|
+
if (key.name === 'backspace') { state.query = state.query.slice(0, -1); draw(state); return }
|
|
665
|
+
if (str && str.length === 1 && !key.ctrl) { state.query += str; draw(state); return }
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
if (state.help) { if (key.name === 'escape' || str === '?' || str === 'q') { state.help = false; draw(state) } return }
|
|
669
|
+
if (str === 'q') { cleanup(); process.exit(0) }
|
|
670
|
+
if (str === '?') { state.help = true; draw(state); return }
|
|
671
|
+
if (str === '/') { state.searchMode = true; draw(state); return }
|
|
672
|
+
if (str === 'L') { state.readerCard = CARDS.find((c) => c.id === 'longform') || CARDS[0]; state.readerBack = state.screen; state.readerScroll = 0; state.screen = 'reader'; draw(state); return }
|
|
673
|
+
if (key.name === 'tab') { cycle(key.shift ? -1 : 1); return }
|
|
674
|
+
if (/^[1-6]$/.test(str)) {
|
|
675
|
+
const id = ['library', 'audit', 'graph', 'status', 'recycle', 'settings'][Number(str) - 1]
|
|
676
|
+
state.screen = id; state.index = 0; draw(state); return
|
|
677
|
+
}
|
|
678
|
+
if (state.screen === 'reader') {
|
|
679
|
+
if (key.name === 'escape') { state.screen = state.readerBack; draw(state); return }
|
|
680
|
+
if (key.name === 'up' || str === 'k') { state.readerScroll = Math.max(0, state.readerScroll - 1); draw(state); return }
|
|
681
|
+
if (key.name === 'down' || str === 'j') { state.readerScroll = Math.min(state.readerMax, state.readerScroll + 1); draw(state); return }
|
|
682
|
+
if (key.name === 'pageup') { state.readerScroll = Math.max(0, state.readerScroll - 8); draw(state); return }
|
|
683
|
+
if (key.name === 'pagedown' || str === ' ') { state.readerScroll = Math.min(state.readerMax, state.readerScroll + 8); draw(state); return }
|
|
684
|
+
if (str === 'g') { state.readerScroll = 0; draw(state); return }
|
|
685
|
+
if (str === 'G') { state.readerScroll = state.readerMax; draw(state); return }
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
if (state.screen === 'settings') {
|
|
689
|
+
if (key.name === 'up' || str === 'k') { state.settingsIndex = Math.max(0, state.settingsIndex - 1); draw(state); return }
|
|
690
|
+
if (key.name === 'down' || str === 'j') { state.settingsIndex = Math.min(state.settings.length - 1, state.settingsIndex + 1); draw(state); return }
|
|
691
|
+
if (key.name === 'return' || key.name === 'space' || str === ' ') {
|
|
692
|
+
const s = state.settings[state.settingsIndex]
|
|
693
|
+
if (s) s.value = s.value === '开' ? '关' : s.value === '关' ? '开' : s.value
|
|
694
|
+
draw(state); return
|
|
695
|
+
}
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
if (state.screen === 'recycle') {
|
|
699
|
+
if (key.name === 'up' || str === 'k') { state.recycleIndex = Math.max(0, state.recycleIndex - 1); draw(state); return }
|
|
700
|
+
if (key.name === 'down' || str === 'j') { state.recycleIndex = Math.min(state.recycle.length - 1, state.recycleIndex + 1); draw(state); return }
|
|
701
|
+
if (str === 'r' && state.recycle.length) { const [x] = state.recycle.splice(state.recycleIndex, 1); state.recycleIndex = Math.max(0, state.recycleIndex - 1); toast('✓ 已恢复:' + x.title); return }
|
|
702
|
+
if (str === 'd' && state.recycle.length) { const [x] = state.recycle.splice(state.recycleIndex, 1); state.recycleIndex = Math.max(0, state.recycleIndex - 1); toast('✕ 已彻底删除:' + x.title, false); return }
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
if (state.screen === 'graph') {
|
|
706
|
+
const cards = visible()
|
|
707
|
+
if (key.name === 'up' || str === 'k') { state.index = Math.max(0, state.index - 1); draw(state); return }
|
|
708
|
+
if (key.name === 'down' || str === 'j') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + 1); draw(state); return }
|
|
709
|
+
if (key.name === 'return' || key.name === 'enter') { openReader(); return }
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (state.screen === 'status') return
|
|
713
|
+
const cards = visible()
|
|
714
|
+
if (str === 't' || key.name === 'right' || str === 'l') {
|
|
715
|
+
const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
|
|
716
|
+
state.kind = keys[(keys.indexOf(state.kind) + 1) % keys.length]
|
|
717
|
+
state.index = 0; state.marked.clear(); draw(state); return
|
|
718
|
+
}
|
|
719
|
+
if (key.name === 'left' || str === 'h') {
|
|
720
|
+
const keys = ['all', 'project', 'knowledge', 'mistake', 'content', 'tool', 'prompt']
|
|
721
|
+
state.kind = keys[(keys.indexOf(state.kind) - 1 + keys.length) % keys.length]
|
|
722
|
+
state.index = 0; state.marked.clear(); draw(state); return
|
|
723
|
+
}
|
|
724
|
+
if (state.screen === 'audit' && (str === 's' || (key.ctrl && key.name === 'a'))) {
|
|
725
|
+
const all = cards.length > 0 && cards.every((c) => state.marked.has(c.id))
|
|
726
|
+
cards.forEach((c) => all ? state.marked.delete(c.id) : state.marked.add(c.id))
|
|
727
|
+
draw(state); return
|
|
728
|
+
}
|
|
729
|
+
if (key.name === 'up' || str === 'k') { state.index = Math.max(0, state.index - 1); draw(state); return }
|
|
730
|
+
if (key.name === 'down' || str === 'j') { state.index = Math.min(Math.max(0, cards.length - 1), state.index + 1); draw(state); return }
|
|
731
|
+
if (key.name === 'return' || key.name === 'enter') { openReader(); return }
|
|
732
|
+
if (key.name === 'escape') { state.query = ''; state.kind = 'all'; state.marked.clear(); state.index = 0; draw(state); return }
|
|
733
|
+
if (state.screen === 'audit' && (key.name === 'space' || str === ' ')) {
|
|
734
|
+
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
|
|
735
|
+
}
|
|
736
|
+
if (state.screen === 'audit' && (str === 'a' || str === 'r')) removeSelected(str)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
process.stdin.on('keypress', onKey)
|
|
740
|
+
process.stdout.on('resize', () => draw(state))
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
main()
|