sage-mem 2.1.10 → 2.1.11
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +49 -47
- package/lib/memory/config.js +107 -10
- package/lib/settings.js +36 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.11`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.11`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/index.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* sage-mem — 文件式记忆插件。记忆存在本地 markdown 目录(frontmatter + 正文,4 类),不是数据库。
|
|
3
|
-
* host 侧三件事:①
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写 memory 目录——透明、可检查、
|
|
8
|
-
* 防膨胀。无 worker、无 SQLite、无端口。旧 worker 版见 git 分支 `sqlite-worker`。
|
|
3
|
+
* host 侧三件事:① 注入(assemble 时扫目录、按双字匹配选文件,读全文注入 system prompt);
|
|
4
|
+
* ② TypertRemoteService(设置页 / 面板的文件读写删);③ autodream(可选的记忆整理,见 lib/autodream.js)。
|
|
5
|
+
* 写入靠 AGENTS.md 的「记忆使用」规则引导 agent 自己判断 + 用文件工具写;透明、可检查、防膨胀。
|
|
6
|
+
* 无 worker / 无 SQLite / 无端口;旧 worker 版见 git 分支 `sqlite-worker`。
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
|
|
12
|
-
import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
|
|
10
|
+
import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STARTUP_LIMIT_DIAGNOSTICS, STATE_ROOT, resolveLimits } from './memory/config.js'
|
|
13
11
|
import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
|
|
14
12
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
15
13
|
import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
@@ -27,7 +25,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
|
27
25
|
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
|
28
26
|
import { join } from 'node:path'
|
|
29
27
|
import { checkUpdate as checkUpdateImpl } from './update.js'
|
|
30
|
-
import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch,
|
|
28
|
+
import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pendingUi, rawName, readReservedFile, readSettingsFileDetailed, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
|
|
31
29
|
import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
|
|
32
30
|
import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
|
|
33
31
|
|
|
@@ -81,10 +79,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* 实现全在 `lib/update.js`:**当前版本运行时读自己的 `package.json`**(不内联常量 ——
|
|
87
|
-
* 内联会在「构建早于 bump」时停留在旧版本,插件于是永远对自己报「有新版本」);
|
|
82
|
+
* 查有没有新版本(面板进入时静默调一次)。实现全在 `lib/update.js`:版本运行时读自己的
|
|
83
|
+
* `package.json`(不内联常量 —— 内联会在「构建早于 bump」时停在旧版本、永远报有新版本);
|
|
88
84
|
* 查不到一律降级为「不提示」,绝不在界面上冒红条。
|
|
89
85
|
*/
|
|
90
86
|
async checkUpdate() {
|
|
@@ -92,11 +88,10 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
92
88
|
}
|
|
93
89
|
|
|
94
90
|
/**
|
|
95
|
-
* 最近几次注入尝试的账(1.7.0 面板「工具 →
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* 面板据此不显示入口。不提供「清空 / 修改」能力 —— 一份账记下来就是那一刻的事实。
|
|
91
|
+
* 最近几次注入尝试的账(1.7.0 面板「工具 → 注入调试」的唯一数据源)。**只读、不落盘**:
|
|
92
|
+
* 数据是注入链在 `SAGE_MEM_DEBUG=1` 时记进进程内存的环形缓冲(见 `lib/recall/debug.js`);
|
|
93
|
+
* 开关关着时返回 `{ enabled: false, records: [] }`,面板据此不显示入口。不提供清空 / 修改
|
|
94
|
+
* 能力 —— 一份账记下来就是那一刻的事实。
|
|
100
95
|
*/
|
|
101
96
|
async recallDebug() {
|
|
102
97
|
return readRecallDebug()
|
|
@@ -120,13 +115,9 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
120
115
|
}
|
|
121
116
|
|
|
122
117
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
|
|
127
|
-
*
|
|
128
|
-
* 2.1.2(Q02):返回值多一个 `revision`(正文字节的 sha256)。保存时把它带回来,
|
|
129
|
-
* 盘上版本变了就拒绝覆盖(见 `memory/write-protocol.js`)。
|
|
118
|
+
* 读单个记忆文件全文。过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 ——
|
|
119
|
+
* 符号链接 / 目录联接 / 硬链接都能让一次「读自己的记忆」变成读别人的文件,而内容会进模型上下文。
|
|
120
|
+
* 2.1.2(Q02):返回值多一个 `revision`(正文字节 sha256);保存时带回来,盘上版本变了就拒绝覆盖。
|
|
130
121
|
*/
|
|
131
122
|
async readFile(name) {
|
|
132
123
|
const safe = safeName(name)
|
|
@@ -227,9 +218,8 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
227
218
|
const res = await archiveMemory(MEMORY_DIR, name, reason)
|
|
228
219
|
if (!res.ok) return { ok: false, error: res.error }
|
|
229
220
|
/**
|
|
230
|
-
* ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11
|
|
231
|
-
*
|
|
232
|
-
* 「索引没摘」在面板上要看得见,不能只活在 host 的返回值里被 strict 剥掉。
|
|
221
|
+
* ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):归档成功、
|
|
222
|
+
* 索引却没摘掉时,这正是用户该被告知的那件事 —— 面板上要看得见,不能只活在 host 返回值里被 strict 剥掉。
|
|
233
223
|
*/
|
|
234
224
|
return {
|
|
235
225
|
ok: true,
|
|
@@ -257,15 +247,11 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
257
247
|
}
|
|
258
248
|
|
|
259
249
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision。
|
|
267
|
-
* 删除确认必须把它交回来 —— `archivedAt` 只是展示时间,同一分钟内不可区分,
|
|
268
|
-
* 拿它当版本会让「A 改成同时间戳的 B」和「同分钟重新归档」的旧确认删掉新内容。
|
|
250
|
+
* 列出归档区的全部记忆(面板的「已归档」区)。与 `listFiles()` 分开、形状也不同:那个只列
|
|
251
|
+
* 活动记忆(多一个字段都会破坏界面与断言依赖的形状);归档条目额外带 `archivedAt` /
|
|
252
|
+
* `archivedReason` / `revision`。旧版 autodream 工具直接移文件、不写留痕,所以前两个可能是空串。
|
|
253
|
+
* `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision,删除确认必须原样交回 ——
|
|
254
|
+
* `archivedAt` 只是展示时间,同一分钟内不可区分,拿它当版本会让旧确认删掉新内容。
|
|
269
255
|
*/
|
|
270
256
|
async listArchived() {
|
|
271
257
|
const files = await scanArchived(this.ctx)
|
|
@@ -446,14 +432,12 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
446
432
|
}
|
|
447
433
|
|
|
448
434
|
/**
|
|
449
|
-
* 面板上的注入设置(5 项上限 +
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* 「当前生效值」`limits` 与「待生效值」`pendingLimits`,外加 `restartRequired` ——
|
|
453
|
-
* 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效又看不到变化。
|
|
435
|
+
* 面板上的注入设置(5 项上限 + 保留名保护名单)。⚠️ **这 5 个常量是模块加载时读的**:面板改完
|
|
436
|
+
* 要**重启 DSH** 才生效。所以这里同时给出「当前生效值」`limits`、「待生效值」`pendingLimits` 与
|
|
437
|
+
* `restartRequired` —— 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效。
|
|
454
438
|
*/
|
|
455
439
|
async getSettings() {
|
|
456
|
-
const
|
|
440
|
+
const fileRead = await readSettingsFileDetailed()
|
|
457
441
|
const limits = {
|
|
458
442
|
maxResults: MAX_RESULTS,
|
|
459
443
|
maxChars: MAX_CHARS_PER_FILE,
|
|
@@ -461,19 +445,31 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
461
445
|
maxSessionBytes: MAX_SESSION_BYTES,
|
|
462
446
|
staleDays: STALE_DAYS,
|
|
463
447
|
}
|
|
464
|
-
|
|
448
|
+
// Q04:pending 与**启动解析**共用 `resolveLimits` 这一份判据(持久设置 > 环境变量 > 默认)。
|
|
449
|
+
const pending = resolveLimits(fileRead.data?.limits)
|
|
450
|
+
// 坏设置给明确诊断、不静默回默认:启动期那份 + 这次读文件的问题 + 逐项非法/越界说明,去重后一起给。
|
|
451
|
+
const limitDiagnostics = [...new Set([
|
|
452
|
+
...STARTUP_LIMIT_DIAGNOSTICS,
|
|
453
|
+
...(fileRead.error ? [fileRead.error] : []),
|
|
454
|
+
...pending.diagnostics,
|
|
455
|
+
])]
|
|
456
|
+
if (limitDiagnostics.length > 0) {
|
|
457
|
+
warnLog(this.ctx, `上限设置有问题(本次仍按环境变量/默认值生效):${limitDiagnostics.join(';')}`)
|
|
458
|
+
}
|
|
465
459
|
const reservedExtra = await readReservedFile()
|
|
466
460
|
// 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
|
|
467
461
|
const activeExtra = [...RESERVED_FILES].filter((n) => n !== INDEX_FILE_KEY && n !== SESSION_LOG_KEY)
|
|
468
462
|
const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
|
|
469
463
|
const restartRequired =
|
|
470
|
-
JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
|
|
464
|
+
JSON.stringify(limits) !== JSON.stringify(pending.limits) || norm(activeExtra) !== norm(reservedExtra)
|
|
471
465
|
return {
|
|
472
466
|
limits,
|
|
473
|
-
pendingLimits: pending,
|
|
467
|
+
pendingLimits: pending.limits,
|
|
474
468
|
restartRequired,
|
|
469
|
+
// Q04 的诊断出口:面板据此说明「你保存的值没生效,原因是 X」;宿主侧先如实给出,别让问题只能靠猜。
|
|
470
|
+
limitDiagnostics,
|
|
475
471
|
// 1.8.0:客户端行为(侧栏入口开关)。**不参与** restartRequired —— 改完立刻生效。
|
|
476
|
-
ui: pendingUi(
|
|
472
|
+
ui: pendingUi(fileRead.data),
|
|
477
473
|
// 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
|
|
478
474
|
reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
|
|
479
475
|
reservedExtra,
|
|
@@ -526,7 +522,13 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
526
522
|
|
|
527
523
|
await mkdir(STATE_ROOT, { recursive: true })
|
|
528
524
|
if (limitsPatch || uiPatch !== null) {
|
|
529
|
-
const
|
|
525
|
+
const currentRead = await readSettingsFileDetailed()
|
|
526
|
+
// 坏文件上不写:没有可合并的旧值,整份重写会把用户手改的内容静默吞掉。
|
|
527
|
+
// 明确拒绝、让用户先修文件,比「保存成功但别的设置都没了」诚实。
|
|
528
|
+
if (currentRead.error) {
|
|
529
|
+
return { ok: false, error: `sage-mem: ${currentRead.error};请先修复或删除它再保存(不会静默覆盖)` }
|
|
530
|
+
}
|
|
531
|
+
const current = currentRead.data
|
|
530
532
|
const mergedLimits = { ...(current?.limits ?? {}), ...nextLimits }
|
|
531
533
|
// ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「开」)
|
|
532
534
|
const mergedUi = { ...pendingUi(current), ...(uiPatch ?? {}) }
|
package/lib/memory/config.js
CHANGED
|
@@ -18,6 +18,15 @@ export const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem
|
|
|
18
18
|
*/
|
|
19
19
|
export const SESSIONS_ROOT = process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : ''
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 状态根:配置、锁表、台账、冲突、检查点、快照都住在这里。
|
|
23
|
+
*
|
|
24
|
+
* `SAGE_MEM_STATE_DIR` 显式覆写优先;没设时跟着记忆目录走(它的上一层 + `.sage-mem`)。
|
|
25
|
+
* ⚠️ Q09:**所有子模块都必须从这里取**,不许各自 `dirname(memoryDir)` + 拼 `.sage-mem` ——
|
|
26
|
+
* 那样覆写只对一部分模块生效,面板与引擎会各读一套配置。
|
|
27
|
+
*/
|
|
28
|
+
export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
|
|
29
|
+
|
|
21
30
|
/** 读一个整数型环境变量并在范围内收敛;取不到或非法就用默认值。 */
|
|
22
31
|
export function envInt(name, dflt, lo, hi) {
|
|
23
32
|
// ⚠️ 必须先 trim:`Number(' ')` 是 **0** 而不是 NaN,于是纯空格的值会被
|
|
@@ -41,20 +50,110 @@ export const LIMIT_SPECS = {
|
|
|
41
50
|
maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
|
|
42
51
|
staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* 单项上限的**唯一**校验:非法返回 `undefined`,合法返回收敛到 `[lo, hi]` 的整数。 *
|
|
55
|
+
* 与 `envInt()` 同一套语义,区别只在「非法」的出口由调用方决定(环境变量回落默认、
|
|
56
|
+
* 持久设置回落环境变量并留诊断)。**必须先 trim**:`Number(' ')` 是 0 而不是 NaN,
|
|
57
|
+
* 不 trim 的话纯空格会被当成「用户填了 0」。
|
|
58
|
+
*/
|
|
59
|
+
export function parseLimitValue(raw, spec) {
|
|
60
|
+
if (typeof raw === 'number') {
|
|
61
|
+
return Number.isFinite(raw) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(raw))) : undefined
|
|
62
|
+
}
|
|
63
|
+
if (typeof raw !== 'string') return undefined
|
|
64
|
+
const text = raw.trim()
|
|
65
|
+
if (text === '') return undefined
|
|
66
|
+
const n = Number(text)
|
|
67
|
+
if (!Number.isFinite(n)) return undefined
|
|
68
|
+
return Math.max(spec.lo, Math.min(spec.hi, Math.round(n)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 五项上限的**单一解析器**:持久设置 > 环境变量 > 内置默认。
|
|
73
|
+
*
|
|
74
|
+
* 为什么必须只有这一份(Q04):旧实现里「启动生成的常量」只读环境变量,而面板的
|
|
75
|
+
* `pendingLimits` 另写一套读 settings.json —— 于是 UI 永远显示「待重启」,
|
|
76
|
+
* 重启却永远不会加载它。两处共用本函数后,优先级与校验不可能再各说一套。
|
|
77
|
+
*
|
|
78
|
+
* 诊断不静默:持久值不是数字 → 回落环境变量/默认并说明;越界 → 收敛到边界并说明。
|
|
79
|
+
* 缺失(`hasOwnProperty` 为假)是正常状态,不产生诊断。
|
|
80
|
+
*
|
|
81
|
+
* @param {object|null|undefined} saved — `settings.json` 的 `limits` 段
|
|
82
|
+
* @param {object} [env] — 默认 `process.env`(测试可注入,避免污染进程环境)
|
|
83
|
+
* @returns {{limits: Record<string, number>, diagnostics: string[]}}
|
|
84
|
+
*/
|
|
85
|
+
export function resolveLimits(saved, env = process.env) {
|
|
86
|
+
const limits = {}
|
|
87
|
+
const diagnostics = []
|
|
88
|
+
const savedObj = saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : null
|
|
89
|
+
for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
|
|
90
|
+
const hasSaved = savedObj !== null && Object.prototype.hasOwnProperty.call(savedObj, key)
|
|
91
|
+
let note = ''
|
|
92
|
+
if (hasSaved) {
|
|
93
|
+
const value = parseLimitValue(savedObj[key], spec)
|
|
94
|
+
if (value !== undefined) {
|
|
95
|
+
if (String(savedObj[key]).trim() !== String(value)) {
|
|
96
|
+
note = `settings.json 的 ${key}=${JSON.stringify(savedObj[key])} 不是 [${spec.lo}, ${spec.hi}] 内的整数,已收敛为 ${value}`
|
|
97
|
+
}
|
|
98
|
+
limits[key] = value
|
|
99
|
+
if (note) diagnostics.push(note)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
note = `settings.json 的 ${key}=${JSON.stringify(savedObj[key])} 不是有效数字,已回落环境变量/默认值`
|
|
103
|
+
diagnostics.push(note)
|
|
104
|
+
}
|
|
105
|
+
const envValue = parseLimitValue(env?.[spec.env], spec)
|
|
106
|
+
limits[key] = envValue === undefined ? spec.dflt : envValue
|
|
107
|
+
}
|
|
108
|
+
return { limits, diagnostics }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 启动期同步读 `<状态根>/settings.json`(模块常量只能同步取)。
|
|
113
|
+
*
|
|
114
|
+
* 「文件不存在」是正常状态(还未从面板保存过);坏 JSON / 不是对象 / 读不动都返回
|
|
115
|
+
* 一条诊断 —— **不静默回落默认**是 Q04 的验收项之一。
|
|
116
|
+
*/
|
|
117
|
+
export function readSettingsFileSync(stateRoot = STATE_ROOT) {
|
|
118
|
+
const file = join(stateRoot, 'settings.json')
|
|
119
|
+
let text
|
|
120
|
+
try {
|
|
121
|
+
text = readFileSync(file, 'utf8')
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err?.code === 'ENOENT') return { file, data: null, diagnostics: [] }
|
|
124
|
+
return { file, data: null, diagnostics: [`读 ${file} 失败(${err?.code || err?.message}),本次按环境变量/默认值生效`] }
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(text)
|
|
128
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
129
|
+
return { file, data: null, diagnostics: [`${file} 不是 JSON 对象,本次按环境变量/默认值生效`] }
|
|
130
|
+
}
|
|
131
|
+
return { file, data: parsed, diagnostics: [] }
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { file, data: null, diagnostics: [`${file} 解析失败(${err?.message ?? err}),本次按环境变量/默认值生效`] }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 启动期一次性解析的结果。**面板与引擎看到的是同一份**(`getSettings` 的 pending
|
|
139
|
+
* 走同一个 `resolveLimits`,只是输入换了下一次读到的文件内容)。
|
|
140
|
+
*/
|
|
141
|
+
const STARTUP_SETTINGS = readSettingsFileSync()
|
|
142
|
+
const STARTUP_LIMITS = resolveLimits(STARTUP_SETTINGS.data?.limits)
|
|
143
|
+
export const STARTUP_LIMIT_DIAGNOSTICS = [...STARTUP_SETTINGS.diagnostics, ...STARTUP_LIMITS.diagnostics]
|
|
45
144
|
|
|
46
145
|
/**
|
|
47
146
|
* 一次注入最多带几条记忆。
|
|
48
147
|
*
|
|
49
148
|
* 5 → 10:**记忆条数 ÷ 注入名额 ≈ 命中率上限**,名额 5 时那个上限低到
|
|
50
149
|
* 的根因就在这个比值上。名额翻倍是提升召回最直接的一手,代价是每轮多约 7.5K
|
|
51
|
-
*
|
|
52
|
-
*
|
|
150
|
+
* 字符的上下文。**这个数字该由实测决定**,所以留了设置页(`settings.json`)与
|
|
151
|
+
* `SAGE_MEM_MAX_RESULTS` 两个覆写口(优先级:设置页 > 环境变量 > 默认值)。
|
|
53
152
|
*/
|
|
54
|
-
export const MAX_RESULTS =
|
|
153
|
+
export const MAX_RESULTS = STARTUP_LIMITS.limits.maxResults
|
|
55
154
|
|
|
56
155
|
/** 单条记忆注入的字符上限。 */
|
|
57
|
-
export const MAX_CHARS_PER_FILE =
|
|
156
|
+
export const MAX_CHARS_PER_FILE = STARTUP_LIMITS.limits.maxChars
|
|
58
157
|
|
|
59
158
|
/**
|
|
60
159
|
* baseline 名额。
|
|
@@ -63,7 +162,7 @@ export const MAX_CHARS_PER_FILE = fromSpec(LIMIT_SPECS.maxChars)
|
|
|
63
162
|
* 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
|
|
64
163
|
* 排序见下面「按显式优先级排,不按文件名」那段。
|
|
65
164
|
*/
|
|
66
|
-
export const MAX_BASELINE =
|
|
165
|
+
export const MAX_BASELINE = STARTUP_LIMITS.limits.maxBaseline
|
|
67
166
|
|
|
68
167
|
/**
|
|
69
168
|
* 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
|
|
@@ -77,7 +176,7 @@ export const MAX_BASELINE = fromSpec(LIMIT_SPECS.maxBaseline)
|
|
|
77
176
|
* 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
|
|
78
177
|
* 覆写口,量过再定。
|
|
79
178
|
*/
|
|
80
|
-
export const MAX_SESSION_BYTES =
|
|
179
|
+
export const MAX_SESSION_BYTES = STARTUP_LIMITS.limits.maxSessionBytes
|
|
81
180
|
|
|
82
181
|
/**
|
|
83
182
|
* 状态卡预留占总预算的比例,以及它的上下夹逼区间。
|
|
@@ -108,7 +207,7 @@ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
|
|
|
108
207
|
* 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
|
|
109
208
|
* 本身会让它显得更权威、而不是更可疑。
|
|
110
209
|
*/
|
|
111
|
-
export const STALE_DAYS =
|
|
210
|
+
export const STALE_DAYS = STARTUP_LIMITS.limits.staleDays
|
|
112
211
|
|
|
113
212
|
/**
|
|
114
213
|
* 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
|
|
@@ -144,8 +243,6 @@ export const RECALL_DISCIPLINE = [
|
|
|
144
243
|
* 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
|
|
145
244
|
* 就能整份覆盖索引,deleteFile 还能不可逆删掉它)。
|
|
146
245
|
*/
|
|
147
|
-
export const STATE_ROOT = process.env.SAGE_MEM_STATE_DIR || join(dirname(MEMORY_DIR), '.sage-mem')
|
|
148
|
-
|
|
149
246
|
/**
|
|
150
247
|
* 2.0.0:有效性过滤(`invalid_at`)与矛盾抑制的总开关(设计稿 §11.2 Q8)。
|
|
151
248
|
*
|
package/lib/settings.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
|
-
import { LIMIT_SPECS, STATE_ROOT } from './memory/config.js'
|
|
9
|
+
import { LIMIT_SPECS, STATE_ROOT, resolveLimits } from './memory/config.js'
|
|
10
10
|
import { MAX_RAW_BYTES } from './shared/limits.js'
|
|
11
11
|
import { isPlainMdName } from './shared/md-name.js'
|
|
12
12
|
export { LIMIT_SPECS, MAX_RAW_BYTES }
|
|
@@ -53,30 +53,46 @@ export function parseUiPatch(ui) {
|
|
|
53
53
|
return { ok: true, patch: Object.keys(patch).length ? patch : null }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* 读 `<状态根>/settings.json`,并把「为什么没读到」说清楚。
|
|
58
|
+
*
|
|
59
|
+
* 缺失是正常状态(`data:null, error:''`);坏 JSON / 不是对象返回 `error` ——
|
|
60
|
+
* Q04 要求坏设置给明确诊断,而不是让调用方一律看到 `null`、分不清「没设置过」
|
|
61
|
+
* 与「文件被我改坏了」。启动期的同步版本在 `memory/config.js`(语义一致)。
|
|
62
|
+
*/
|
|
63
|
+
export async function readSettingsFileDetailed() {
|
|
64
|
+
let text
|
|
58
65
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return null
|
|
66
|
+
text = await readFile(SETTINGS_FILE, 'utf8')
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err?.code === 'ENOENT') return { data: null, error: '' }
|
|
69
|
+
return { data: null, error: `读 settings.json 失败(${err?.code || err?.message})` }
|
|
70
|
+
}
|
|
71
|
+
let parsed
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(text)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { data: null, error: `settings.json 解析失败(${err?.message ?? err})` }
|
|
76
|
+
}
|
|
77
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
78
|
+
return { data: null, error: 'settings.json 不是 JSON 对象' }
|
|
63
79
|
}
|
|
80
|
+
return { data: parsed, error: '' }
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
/**
|
|
83
|
+
/** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」(要区分请用上面那个)。 */
|
|
84
|
+
export async function readSettingsFile() {
|
|
85
|
+
return (await readSettingsFileDetailed()).data
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。
|
|
90
|
+
*
|
|
91
|
+
* ⚠️ 判据**只有** `config.js` 的 `resolveLimits` 一份(Q04 的根因就是这里另写了一套):
|
|
92
|
+
* 启动生成常量与面板算 pending 必须共用同一份校验与优先级,否则 UI 会一直喊「待重启」。
|
|
93
|
+
*/
|
|
67
94
|
export function pendingLimits(fileObj) {
|
|
68
|
-
|
|
69
|
-
const saved = fileObj?.limits && typeof fileObj.limits === 'object' ? fileObj.limits : {}
|
|
70
|
-
for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
|
|
71
|
-
const fromFile = Number(saved[key])
|
|
72
|
-
if (Number.isFinite(fromFile)) {
|
|
73
|
-
out[key] = clampInt(fromFile, spec)
|
|
74
|
-
continue
|
|
75
|
-
}
|
|
76
|
-
const raw = process.env[spec.env]
|
|
77
|
-
out[key] = clampInt(raw === undefined || raw === '' ? NaN : Number(raw), spec)
|
|
78
|
-
}
|
|
79
|
-
return out
|
|
95
|
+
return resolveLimits(fileObj?.limits).limits
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
/** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
|
package/package.json
CHANGED