memory-eternal 0.22.0 → 0.24.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 +133 -1
- package/README.zh-CN.md +133 -1
- 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 +78 -0
- package/lib/api.js +156 -17
- package/lib/client.js +93 -23
- package/lib/news-digest.js +109 -0
- package/lib/news-follows.js +523 -0
- package/lib/search-index.js +212 -0
- package/lib/vault.js +90 -15
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/scripts/bench-search.mjs +64 -0
- package/web/app.js +98 -28
package/lib/api.js
CHANGED
|
@@ -17,9 +17,11 @@ import fs, { readFileSync } from 'node:fs'
|
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { fileURLToPath } from 'node:url'
|
|
19
19
|
import { spawn } from 'node:child_process'
|
|
20
|
-
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, graphLevel, LEVEL_ERR_PATH, LEVEL_ERR_STALE, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies, updateCardContent, reviseCard } from './vault.js'
|
|
20
|
+
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, graphLevel, LEVEL_ERR_PATH, LEVEL_ERR_STALE, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, purgeAllRecycle, setCardStatus, setCardKind, restoreCard, mainStoreBodies, updateCardContent, reviseCard } from './vault.js'
|
|
21
21
|
import { compressExcerpt, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
22
22
|
import { buildDiagnostics } from './feedback.js'
|
|
23
|
+
import { FOLLOW_DOMAINS, parseFollowIntent, listFollows, saveFollow, removeFollow, refreshFollows, followItems } from './news-follows.js'
|
|
24
|
+
import { buildDigest, readDigest, digestPrompt } from './news-digest.js'
|
|
23
25
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
24
26
|
import { ledgerSummary } from './ledger.js'
|
|
25
27
|
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
@@ -199,7 +201,30 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
199
201
|
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
200
202
|
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
201
203
|
*/
|
|
202
|
-
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|card\/update|card\/revise|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
204
|
+
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|card\/update|card\/revise|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired|purge-all)|backup)$/
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 解析回收站保留期参数 `?days=`。
|
|
208
|
+
*
|
|
209
|
+
* issue #57 的根因就藏在一行 `Number(query.get('days')) || 30` 里:`Number('0')` 是 0,
|
|
210
|
+
* 而 `0 || 30` 又变回 30 —— 前端「清空回收站」传的 0 被静默升级成 30 天保留期,
|
|
211
|
+
* 刚删的卡一张都不清,用户看到的就是「点了没反应」。
|
|
212
|
+
*
|
|
213
|
+
* 这里把「没传」与「显式传 0」分开;非法输入交给调用方转 400。
|
|
214
|
+
* @param {string|null|undefined} raw
|
|
215
|
+
* @param {number} fallback 未提供时的默认保留天数
|
|
216
|
+
* @returns {number}
|
|
217
|
+
*/
|
|
218
|
+
export function parseRetentionDays(raw, fallback = 30) {
|
|
219
|
+
if (raw === null || raw === undefined || raw === '') return fallback
|
|
220
|
+
const n = Number(raw)
|
|
221
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
222
|
+
const err = new Error('days 必须是非负数字')
|
|
223
|
+
err.code = 'BAD_DAYS'
|
|
224
|
+
throw err
|
|
225
|
+
}
|
|
226
|
+
return n
|
|
227
|
+
}
|
|
203
228
|
|
|
204
229
|
export function createApi(deps = {}) {
|
|
205
230
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
@@ -664,26 +689,127 @@ export function createApi(deps = {}) {
|
|
|
664
689
|
}
|
|
665
690
|
case '/news/feed': {
|
|
666
691
|
// 真实 AI 资讯:服务端按固定白名单抓 RSS / HN,缓存 20 分钟;只存摘要与外链。
|
|
692
|
+
// 带 follows=1 时主 feed 允许降级:9 个 RSS 全挂也不该把用户的 GitHub/arXiv 订阅一起吞掉。
|
|
693
|
+
const wantFollows = query.get('follows') === '1'
|
|
667
694
|
try {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
695
|
+
let data
|
|
696
|
+
try {
|
|
697
|
+
data = await getNewsFeed(vaultRoot, {
|
|
698
|
+
period: query.get('period') === 'month' ? 'month' : 'today',
|
|
699
|
+
type: query.get('type') || '',
|
|
700
|
+
q: query.get('q') || '',
|
|
701
|
+
// lang 口径:'' / 'all' = 不过滤(一次请求拿中英全部条目);zh / en 为显式过滤。
|
|
702
|
+
lang: (() => {
|
|
703
|
+
const raw = String(query.get('lang') == null ? '' : query.get('lang')).trim().toLowerCase()
|
|
704
|
+
if (raw === 'en') return 'en'
|
|
705
|
+
if (raw === 'zh') return 'zh'
|
|
706
|
+
return ''
|
|
707
|
+
})(),
|
|
708
|
+
force: query.get('force') === '1',
|
|
709
|
+
})
|
|
710
|
+
} catch (e) {
|
|
711
|
+
if (!wantFollows) throw e
|
|
712
|
+
// 降级:主 feed 拉不到就返回空集合 + 可读错误,但继续给关注结果。
|
|
713
|
+
data = {
|
|
714
|
+
ok: true, period: 'today', lang: '', ranking: false, degraded: true,
|
|
715
|
+
items: [], types: [], counts: {}, fetchedAt: 0, stale: true,
|
|
716
|
+
errors: ['主 feed 暂不可用:' + String((e && e.message) || e)], sources: [],
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// follows=1:把「关注」命中的条目并进主 feed(同一套 item 形状,前端零改动)。
|
|
720
|
+
// 关注抓取有 30 分钟 TTL,所以这里不会每次都打外网。
|
|
721
|
+
if (wantFollows) {
|
|
722
|
+
const follows = await refreshFollows(vaultRoot)
|
|
723
|
+
const extra = await followItems(vaultRoot, follows)
|
|
724
|
+
const seen = new Set((data.items || []).map((x) => x.url))
|
|
725
|
+
data = {
|
|
726
|
+
...data,
|
|
727
|
+
items: [...extra.filter((x) => x.url && !seen.has(x.url)), ...(data.items || [])].slice(0, 160),
|
|
728
|
+
follows: follows.map((f) => ({
|
|
729
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains,
|
|
730
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
731
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
732
|
+
fetchedAt: (f.cache && f.cache.fetchedAt) || 0,
|
|
733
|
+
})),
|
|
734
|
+
}
|
|
735
|
+
}
|
|
681
736
|
json(res, 200, data)
|
|
682
737
|
} catch (e) {
|
|
683
738
|
json(res, 502, { ok: false, error: '资讯源暂不可用:' + String((e && e.message) || e) })
|
|
684
739
|
}
|
|
685
740
|
return
|
|
686
741
|
}
|
|
742
|
+
case '/news/follows': {
|
|
743
|
+
// GET 列表;POST 创建;DELETE 删除。创建只接受「已解析好的」结构化入参,
|
|
744
|
+
// 域必须来自白名单枚举(parseFollowIntent 也只产出枚举内的值)。
|
|
745
|
+
if (req.method === 'DELETE') {
|
|
746
|
+
const id = String(query.get('id') || '').trim()
|
|
747
|
+
if (!id) return json(res, 400, { ok: false, error: '缺少 id' })
|
|
748
|
+
const out = await removeFollow(vaultRoot, id)
|
|
749
|
+
return json(res, 200, { ok: true, ...out })
|
|
750
|
+
}
|
|
751
|
+
if (req.method === 'POST') {
|
|
752
|
+
const body = await readBody(req)
|
|
753
|
+
try {
|
|
754
|
+
const out = await saveFollow(vaultRoot, {
|
|
755
|
+
query: body.query, domains: body.domains, label: body.label, sourceIds: body.sourceIds,
|
|
756
|
+
})
|
|
757
|
+
return json(res, 200, { ok: true, ...out, domainsEnum: FOLLOW_DOMAINS })
|
|
758
|
+
} catch (e) {
|
|
759
|
+
return json(res, 400, { ok: false, error: String((e && e.message) || e) })
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const follows = await listFollows(vaultRoot)
|
|
763
|
+
return json(res, 200, {
|
|
764
|
+
ok: true,
|
|
765
|
+
domains: FOLLOW_DOMAINS,
|
|
766
|
+
follows: follows.map((f) => ({
|
|
767
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains, createdAt: f.createdAt, lastRunAt: f.lastRunAt,
|
|
768
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
769
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
770
|
+
})),
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
case '/news/follows/preview': {
|
|
774
|
+
// 只解析、不落库:用户先看到「我理解成了什么」,再决定要不要订阅。
|
|
775
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
776
|
+
const body = await readBody(req)
|
|
777
|
+
return json(res, 200, { ok: true, parsed: parseFollowIntent(body.text || ''), domainsEnum: FOLLOW_DOMAINS })
|
|
778
|
+
}
|
|
779
|
+
case '/news/follows/refresh': {
|
|
780
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
781
|
+
const body = await readBody(req).catch(() => ({}))
|
|
782
|
+
const follows = await refreshFollows(vaultRoot, { force: body && body.force === true })
|
|
783
|
+
return json(res, 200, {
|
|
784
|
+
ok: true,
|
|
785
|
+
follows: follows.map((f) => ({
|
|
786
|
+
id: f.id, label: f.label, query: f.query, domains: f.domains,
|
|
787
|
+
count: (f.cache && f.cache.items ? f.cache.items : []).length,
|
|
788
|
+
errors: (f.cache && f.cache.errors) || [],
|
|
789
|
+
})),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
case '/news/digest': {
|
|
793
|
+
// GET:读当前快照;POST:现在生成一份(配置页「立即生成」按钮用,也是开启前的预览)。
|
|
794
|
+
if (req.method === 'POST') {
|
|
795
|
+
const body = await readBody(req).catch(() => ({}))
|
|
796
|
+
const cfg = (getSettings && getSettings()) || {}
|
|
797
|
+
const intervalMin = Math.max(30, Number(cfg.newsDigestIntervalMinutes) || 720)
|
|
798
|
+
try {
|
|
799
|
+
const digest = await buildDigest(vaultRoot, {
|
|
800
|
+
windowHours: Number(body.windowHours) || Math.max(1, Math.round(intervalMin / 60)),
|
|
801
|
+
maxItems: Number(body.maxItems) || Number(cfg.newsDigestMaxItems) || 5,
|
|
802
|
+
force: body.force === true,
|
|
803
|
+
})
|
|
804
|
+
return json(res, 200, { ok: true, digest, text: digestPrompt(digest, { maxItems: Number(cfg.newsDigestMaxItems) || 5 }) })
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return json(res, 502, { ok: false, error: '生成摘要失败:' + String((e && e.message) || e) })
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const digest = await readDigest(vaultRoot)
|
|
810
|
+
const cfgGet = (getSettings && getSettings()) || {}
|
|
811
|
+
return json(res, 200, { ok: true, digest, text: digest ? digestPrompt(digest, { maxItems: Number(cfgGet.newsDigestMaxItems) || 5 }) : '' })
|
|
812
|
+
}
|
|
687
813
|
case '/news/feed/refresh': {
|
|
688
814
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
|
|
689
815
|
try {
|
|
@@ -876,11 +1002,24 @@ export function createApi(deps = {}) {
|
|
|
876
1002
|
return
|
|
877
1003
|
}
|
|
878
1004
|
case '/recycle/purge-expired': {
|
|
879
|
-
|
|
1005
|
+
// issue #57 根因:过去写的是 `Number(query.get('days')) || 30` —— `Number('0')` 是 0,
|
|
1006
|
+
// 而 `0 || 30` 又变回 30,于是前端「清空回收站」传的 days=0 被静默升级成 30 天保留期,
|
|
1007
|
+
// 刚删的卡一个都不清 → 用户看到的就是「点了没反应」。
|
|
1008
|
+
// 现在区分「没传」与「传了 0」:没传才用默认 30。
|
|
1009
|
+
let days
|
|
1010
|
+
try { days = parseRetentionDays(query.get('days')) } catch (e) { return json(res, 400, { ok: false, error: String(e.message) }) }
|
|
880
1011
|
const r = await purgeExpired(vaultRoot, days)
|
|
881
1012
|
json(res, 200, r)
|
|
882
1013
|
return
|
|
883
1014
|
}
|
|
1015
|
+
case '/recycle/purge-all': {
|
|
1016
|
+
// 永久清空回收站(deleted + rejected)。危险操作:只收 POST,且走 DB_WRITE_ROUTES 闸门。
|
|
1017
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
1018
|
+
const r = await purgeAllRecycle(vaultRoot)
|
|
1019
|
+
broadcast('cards', { reason: 'recycle-purged', purged: r.purged })
|
|
1020
|
+
json(res, 200, r)
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
884
1023
|
case '/write': {
|
|
885
1024
|
const chunks = []
|
|
886
1025
|
for await (const chunk of req) chunks.push(chunk)
|
|
@@ -1210,7 +1349,7 @@ export function createApi(deps = {}) {
|
|
|
1210
1349
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
|
|
1211
1350
|
enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '', captureFollowSession: cfg.captureFollowSession === true, captureFirstOnly: cfg.captureFirstOnly === true, captureTimeoutMs: cfg.captureTimeoutMs ?? 45000,
|
|
1212
1351
|
captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
|
|
1213
|
-
skillCardsAutoSync: cfg.skillCardsAutoSync ?? true,
|
|
1352
|
+
skillCardsAutoSync: cfg.skillCardsAutoSync ?? true, newsDigestEnabled: cfg.newsDigestEnabled === true, newsDigestIntervalMinutes: cfg.newsDigestIntervalMinutes ?? 720, newsDigestMaxItems: cfg.newsDigestMaxItems ?? 5,
|
|
1214
1353
|
webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
|
|
1215
1354
|
}
|
|
1216
1355
|
const NUM_RANGE = {
|