memory-eternal 0.18.2 → 0.21.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.md +3 -3
- package/README.zh-CN.md +3 -3
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/index.js +71 -3
- package/lib/api.js +138 -2
- package/lib/capture-run.js +4 -2
- package/lib/capture.js +49 -35
- package/lib/client.js +151 -19
- package/lib/db.js +5 -1
- package/lib/mcp.js +5 -2
- package/lib/news-feed.js +444 -0
- package/lib/news.js +256 -0
- package/lib/skill-cards.js +458 -0
- package/lib/vault.js +693 -1
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +8 -5
- package/web/app.js +156 -24
- package/web/support/group-qr.jpg +0 -0
- package/web/support/group-wechat.jpg +0 -0
package/lib/news.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// 记忆核心 · 动态 / 新闻模块(用户自定义内容)
|
|
2
|
+
//
|
|
3
|
+
// 设计边界:
|
|
4
|
+
// - 新闻不是记忆卡:独立存在 <vault>/news/*.md,不进入 cards / quarantine,
|
|
5
|
+
// 不参与 memory_recall、知识星图、审核队列,避免污染记忆。
|
|
6
|
+
// - 内容完全由用户定义:标题、分类、来源、链接、标签、置顶、Markdown 正文。
|
|
7
|
+
// - 已读状态单独存 news/.state.json,不写进正文,方便直接编辑 Markdown。
|
|
8
|
+
//
|
|
9
|
+
// 文件格式:
|
|
10
|
+
// ---
|
|
11
|
+
// title: ...
|
|
12
|
+
// date: 2026-10-30
|
|
13
|
+
// category: 产品
|
|
14
|
+
// source: GitHub
|
|
15
|
+
// url: https://...
|
|
16
|
+
// tags: [release, skill]
|
|
17
|
+
// pinned: true
|
|
18
|
+
// created: ...
|
|
19
|
+
// updated: ...
|
|
20
|
+
// ---
|
|
21
|
+
// # 标题
|
|
22
|
+
// 正文…
|
|
23
|
+
import fs from 'node:fs/promises'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { safeSlug, normalizeTags } from './vault.js'
|
|
26
|
+
|
|
27
|
+
const NEWS_DIR = 'news'
|
|
28
|
+
const STATE_FILE = '.state.json'
|
|
29
|
+
const MAX_BODY = 200000
|
|
30
|
+
|
|
31
|
+
function newsDir(root) { return path.join(root, NEWS_DIR) }
|
|
32
|
+
function statePath(root) { return path.join(newsDir(root), STATE_FILE) }
|
|
33
|
+
|
|
34
|
+
/** 只允许安全的文件名 id,绝不接受路径分隔符 / ..。 */
|
|
35
|
+
export function safeNewsId(id) {
|
|
36
|
+
const s = String(id || '').trim()
|
|
37
|
+
return /^[\w\u4e00-\u9fff.-]+$/.test(s) && !s.includes('..') ? s : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function yq(value) {
|
|
41
|
+
const s = String(value == null ? '' : value)
|
|
42
|
+
return /[:#\[\]{},&*!|>'"%@`\n]/.test(s) ? JSON.stringify(s) : s
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseBool(v) { return /^(1|true|yes|on)$/i.test(String(v || '').trim()) }
|
|
46
|
+
|
|
47
|
+
/** 解析 news markdown(frontmatter + body)。 */
|
|
48
|
+
export function parseNews(text, fallbackId = '') {
|
|
49
|
+
const raw = String(text || '')
|
|
50
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
|
|
51
|
+
const meta = {}
|
|
52
|
+
let body = raw
|
|
53
|
+
if (m) {
|
|
54
|
+
body = raw.slice(m[0].length)
|
|
55
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
56
|
+
const eq = line.indexOf(':')
|
|
57
|
+
if (eq <= 0) continue
|
|
58
|
+
const key = line.slice(0, eq).trim()
|
|
59
|
+
const value = line.slice(eq + 1).trim()
|
|
60
|
+
meta[key] = value.replace(/^['"]|['"]$/g, '')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const tags = String(meta.tags || '').replace(/^\[/, '').replace(/\]$/, '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
64
|
+
const title = String(meta.title || '').trim() || (body.match(/^#\s+(.+)$/m) || [])[1] || fallbackId || '未命名动态'
|
|
65
|
+
return {
|
|
66
|
+
id: fallbackId,
|
|
67
|
+
title,
|
|
68
|
+
date: String(meta.date || '').trim() || '',
|
|
69
|
+
category: String(meta.category || '').trim() || '未分类',
|
|
70
|
+
source: String(meta.source || '').trim(),
|
|
71
|
+
url: String(meta.url || '').trim(),
|
|
72
|
+
tags: normalizeTags(tags),
|
|
73
|
+
pinned: parseBool(meta.pinned),
|
|
74
|
+
cover: String(meta.cover || '').trim(),
|
|
75
|
+
created: String(meta.created || '').trim(),
|
|
76
|
+
updated: String(meta.updated || '').trim(),
|
|
77
|
+
body: body.trim(),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 序列化为可被人类直接编辑的 Markdown。 */
|
|
82
|
+
export function serializeNews(item) {
|
|
83
|
+
const tags = normalizeTags(item.tags)
|
|
84
|
+
const lines = [
|
|
85
|
+
'---',
|
|
86
|
+
'title: ' + yq(item.title),
|
|
87
|
+
'date: ' + yq(item.date),
|
|
88
|
+
'category: ' + yq(item.category),
|
|
89
|
+
item.source ? 'source: ' + yq(item.source) : '',
|
|
90
|
+
item.url ? 'url: ' + yq(item.url) : '',
|
|
91
|
+
'tags: [' + tags.map(yq).join(', ') + ']',
|
|
92
|
+
'pinned: ' + (item.pinned ? 'true' : 'false'),
|
|
93
|
+
item.cover ? 'cover: ' + yq(item.cover) : '',
|
|
94
|
+
'created: ' + yq(item.created),
|
|
95
|
+
'updated: ' + yq(item.updated),
|
|
96
|
+
'---',
|
|
97
|
+
'',
|
|
98
|
+
'# ' + item.title,
|
|
99
|
+
'',
|
|
100
|
+
item.body.trim(),
|
|
101
|
+
'',
|
|
102
|
+
].filter((x) => x !== '')
|
|
103
|
+
return lines.join('\n')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ensureDir(root) { await fs.mkdir(newsDir(root), { recursive: true }) }
|
|
107
|
+
|
|
108
|
+
async function readState(root) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = await fs.readFile(statePath(root), 'utf8')
|
|
111
|
+
const data = JSON.parse(raw)
|
|
112
|
+
return data && typeof data === 'object' ? data : {}
|
|
113
|
+
} catch { return {} }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function writeState(root, state) {
|
|
117
|
+
await ensureDir(root)
|
|
118
|
+
await fs.writeFile(statePath(root), JSON.stringify(state, null, 2) + '\n', 'utf8')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function itemExcerpt(item) {
|
|
122
|
+
return String(item.body || '').replace(/^#{1,6}\s+/gm, '').replace(/\s+/g, ' ').trim().slice(0, 220)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fromFile(root, file, text, state) {
|
|
126
|
+
const id = file.replace(/\.md$/i, '')
|
|
127
|
+
const item = parseNews(text, id)
|
|
128
|
+
item.read = !!state[id]
|
|
129
|
+
item.path = NEWS_DIR + '/' + file
|
|
130
|
+
item.excerpt = itemExcerpt(item)
|
|
131
|
+
return item
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matches(item, query) {
|
|
135
|
+
const q = String(query || '').trim().toLowerCase()
|
|
136
|
+
if (!q) return true
|
|
137
|
+
const hay = [item.title, item.body, item.category, item.source, item.url, ...(item.tags || [])].join('\n').toLowerCase()
|
|
138
|
+
return q.split(/\s+/).filter(Boolean).every((term) => hay.includes(term))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 列出动态。
|
|
143
|
+
* @returns {Promise<{items:Array, categories:string[], counts:{all:number, unread:number}}>}
|
|
144
|
+
*/
|
|
145
|
+
export async function listNews(root, { q = '', category = '', unreadOnly = false, limit = 500 } = {}) {
|
|
146
|
+
await ensureDir(root)
|
|
147
|
+
const state = await readState(root)
|
|
148
|
+
const files = (await fs.readdir(newsDir(root))).filter((f) => /\.md$/i.test(f))
|
|
149
|
+
const items = []
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
try {
|
|
152
|
+
const text = await fs.readFile(path.join(newsDir(root), file), 'utf8')
|
|
153
|
+
const item = fromFile(root, file, text, state)
|
|
154
|
+
if (!item.date) {
|
|
155
|
+
try { item.date = (await fs.stat(path.join(newsDir(root), file))).mtime.toISOString().slice(0, 10) } catch { item.date = '' }
|
|
156
|
+
}
|
|
157
|
+
items.push(item)
|
|
158
|
+
} catch { /* 单篇读取失败不影响列表 */ }
|
|
159
|
+
}
|
|
160
|
+
const categories = [...new Set(items.map((x) => x.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
161
|
+
const unread = items.filter((x) => !x.read).length
|
|
162
|
+
let filtered = items
|
|
163
|
+
if (category) filtered = filtered.filter((x) => x.category === category)
|
|
164
|
+
if (unreadOnly) filtered = filtered.filter((x) => !x.read)
|
|
165
|
+
filtered = filtered.filter((x) => matches(x, q))
|
|
166
|
+
filtered.sort((a, b) => {
|
|
167
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
|
168
|
+
const da = a.date || a.updated || a.created || ''
|
|
169
|
+
const db = b.date || b.updated || b.created || ''
|
|
170
|
+
return db.localeCompare(da) || String(b.updated || '').localeCompare(String(a.updated || ''))
|
|
171
|
+
})
|
|
172
|
+
const out = filtered.slice(0, limit).map((x) => {
|
|
173
|
+
const { body, ...rest } = x
|
|
174
|
+
return rest
|
|
175
|
+
})
|
|
176
|
+
return { items: out, categories, counts: { all: items.length, unread } }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 读取单篇完整正文。 */
|
|
180
|
+
export async function getNews(root, id) {
|
|
181
|
+
const safe = safeNewsId(id)
|
|
182
|
+
if (!safe) return null
|
|
183
|
+
try {
|
|
184
|
+
const text = await fs.readFile(path.join(newsDir(root), safe + '.md'), 'utf8')
|
|
185
|
+
const state = await readState(root)
|
|
186
|
+
return fromFile(root, safe + '.md', text, state)
|
|
187
|
+
} catch { return null }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 新建 / 更新动态。
|
|
192
|
+
* @returns {Promise<{ok:true,item:object}|{ok:false,error:string}>}
|
|
193
|
+
*/
|
|
194
|
+
export async function saveNews(root, input = {}) {
|
|
195
|
+
const title = String(input.title || '').trim().slice(0, 200)
|
|
196
|
+
const body = String(input.body || '').trim().slice(0, MAX_BODY)
|
|
197
|
+
if (!title) return { ok: false, error: '标题不能为空' }
|
|
198
|
+
if (!body) return { ok: false, error: '正文不能为空' }
|
|
199
|
+
await ensureDir(root)
|
|
200
|
+
const now = new Date().toISOString()
|
|
201
|
+
const date = String(input.date || '').trim().slice(0, 10) || now.slice(0, 10)
|
|
202
|
+
let id = safeNewsId(input.id)
|
|
203
|
+
let created = String(input.created || '').trim()
|
|
204
|
+
let old = null
|
|
205
|
+
if (id) {
|
|
206
|
+
try { old = parseNews(await fs.readFile(path.join(newsDir(root), id + '.md'), 'utf8'), id) } catch { old = null }
|
|
207
|
+
if (!old) return { ok: false, error: '新闻不存在' }
|
|
208
|
+
created = old.created || created || now
|
|
209
|
+
} else {
|
|
210
|
+
const base = date + '-' + safeSlug(title)
|
|
211
|
+
id = base
|
|
212
|
+
let n = 2
|
|
213
|
+
while (await exists(path.join(newsDir(root), id + '.md'))) { id = base + '-' + n; n++ }
|
|
214
|
+
created = created || now
|
|
215
|
+
}
|
|
216
|
+
const item = {
|
|
217
|
+
id,
|
|
218
|
+
title,
|
|
219
|
+
date,
|
|
220
|
+
category: String(input.category || '').trim().slice(0, 40) || '未分类',
|
|
221
|
+
source: String(input.source || '').trim().slice(0, 80),
|
|
222
|
+
url: String(input.url || '').trim().slice(0, 1000),
|
|
223
|
+
tags: normalizeTags(input.tags),
|
|
224
|
+
pinned: !!input.pinned,
|
|
225
|
+
cover: String(input.cover || '').trim().slice(0, 500),
|
|
226
|
+
created,
|
|
227
|
+
updated: now,
|
|
228
|
+
body,
|
|
229
|
+
}
|
|
230
|
+
await fs.writeFile(path.join(newsDir(root), id + '.md'), serializeNews(item), 'utf8')
|
|
231
|
+
return { ok: true, item: { ...item, read: false, path: NEWS_DIR + '/' + id + '.md', excerpt: itemExcerpt(item) } }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function exists(file) { try { await fs.access(file); return true } catch { return false } }
|
|
235
|
+
|
|
236
|
+
/** 删除动态(同时清掉已读状态)。 */
|
|
237
|
+
export async function deleteNews(root, id) {
|
|
238
|
+
const safe = safeNewsId(id)
|
|
239
|
+
if (!safe) return { ok: false, error: '无效的新闻 id' }
|
|
240
|
+
try { await fs.unlink(path.join(newsDir(root), safe + '.md')) } catch { return { ok: false, error: '新闻不存在' } }
|
|
241
|
+
const state = await readState(root)
|
|
242
|
+
if (state[safe]) { delete state[safe]; try { await writeState(root, state) } catch { /* 状态清理失败不影响删除 */ } }
|
|
243
|
+
return { ok: true, id: safe }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 标记已读 / 未读。 */
|
|
247
|
+
export async function setNewsRead(root, id, read = true) {
|
|
248
|
+
const safe = safeNewsId(id)
|
|
249
|
+
if (!safe) return { ok: false, error: '无效的新闻 id' }
|
|
250
|
+
try { await fs.access(path.join(newsDir(root), safe + '.md')) } catch { return { ok: false, error: '新闻不存在' } }
|
|
251
|
+
const state = await readState(root)
|
|
252
|
+
if (read) state[safe] = new Date().toISOString()
|
|
253
|
+
else delete state[safe]
|
|
254
|
+
await writeState(root, state)
|
|
255
|
+
return { ok: true, id: safe, read: !!read }
|
|
256
|
+
}
|