sage-mem 1.2.1 → 1.4.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/README.md +2 -0
- package/README.zh.md +2 -0
- package/lib/autodream/audit.js +13 -18
- package/lib/autodream/prompt.js +2 -7
- package/lib/autodream/tools.js +5 -18
- package/lib/autodream/util.js +2 -13
- package/lib/client.js +74 -138
- package/lib/index.js +6 -5
- package/lib/memory/config.js +24 -10
- package/lib/memory/frontmatter.js +5 -0
- package/lib/memory/index-file.js +2 -1
- package/lib/memory/naming.js +4 -17
- package/lib/memory/scan.js +1 -6
- package/lib/settings.js +7 -27
- package/lib/shared/limits.js +17 -0
- package/lib/shared/md-name.js +25 -0
- package/lib/shared/stamp.js +30 -0
- package/lib/typert.host.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -498,6 +498,8 @@ Module boundaries (since 1.2.0):
|
|
|
498
498
|
- `lib/recall/` — the injection chain: selection (`select`) / rendering and budget (`compose`) / text (`text`) / session state (`state`)
|
|
499
499
|
- `lib/assembly/` — the four mounts: autodream / retrieval tools / observation / write gate
|
|
500
500
|
- `lib/memory/` — pure memory-layer functions and file operations; `lib/autodream/` — the autodream engine
|
|
501
|
+
- `lib/shared/` — zero-dependency cross-layer rules: file-name rule / local timestamps / the two size limits (**exactly one copy each**; `test/duplication-test.mjs` turns red otherwise)
|
|
502
|
+
- UI side: `client/*.js` is split per screen (`05-shared.js` holds the cross-screen primitives and type tables); `npm run build:client` concatenates them into `lib/client.js`
|
|
501
503
|
- Dependency direction: `memory` never imports `autodream`, `recall` depends only on `memory`, `assembly` never imports the composition root (`test/structure-test.mjs` turns red if that changes)
|
|
502
504
|
|
|
503
505
|
## Contributing
|
package/README.zh.md
CHANGED
|
@@ -502,6 +502,8 @@ memory/ 目录(markdown 文件,4 类)
|
|
|
502
502
|
- `lib/recall/` —— 注入链:选材(`select`)/ 渲染与预算(`compose`)/ 文本(`text`)/ 会话状态(`state`)
|
|
503
503
|
- `lib/assembly/` —— 四块挂载:autodream / 检索工具 / 观测 / 写入门
|
|
504
504
|
- `lib/memory/` —— 记忆层的纯函数与文件操作;`lib/autodream/` —— 自动做梦引擎
|
|
505
|
+
- `lib/shared/` —— 跨层零依赖的小判据:文件名规则 / 本地时间戳 / 两个体积上限(**只许一份**,`test/duplication-test.mjs` 会红)
|
|
506
|
+
- 界面侧:`client/*.js` 按屏分片(`05-shared.js` 放跨屏小基元与类型词表),`npm run build:client` 拼成 `lib/client.js`
|
|
505
507
|
- 依赖方向:`memory` 不认识 `autodream`,`recall` 只依赖 `memory`,`assembly` 不反向依赖组合根(`test/structure-test.mjs` 会红)
|
|
506
508
|
|
|
507
509
|
## 参与开发
|
package/lib/autodream/audit.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import { readdir, readFile } from 'node:fs/promises'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
|
+
import { hasFrontmatter, parseFrontmatter } from '../memory/frontmatter.js'
|
|
27
|
+
import { SESSION_LOG_KEY } from '../shared/md-name.js'
|
|
26
28
|
import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
|
|
27
29
|
import { warnLog } from '../memory/util.js'
|
|
28
30
|
|
|
@@ -51,7 +53,7 @@ const STRAY_RE = /\.archiving-[0-9a-f]+$/i
|
|
|
51
53
|
* **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
|
|
52
54
|
* **不是一条记忆** —— 见下面 `f !== INDEX_FILE` 那两处。
|
|
53
55
|
*/
|
|
54
|
-
const AUDIT_SKIP_FILES = new Set([
|
|
56
|
+
const AUDIT_SKIP_FILES = new Set([SESSION_LOG_KEY])
|
|
55
57
|
|
|
56
58
|
/** 归档区目录名(与 autodream-tools.js 的 ARCHIVE_DIR、宿主侧 memory/scan.js 同名)。 */
|
|
57
59
|
const ARCHIVE_DIR = 'archive'
|
|
@@ -60,26 +62,19 @@ const ARCHIVE_DIR = 'archive'
|
|
|
60
62
|
const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* 审计用的 frontmatter 视图:**不再自己写一份正则**,改成正典解析的薄包装。
|
|
66
|
+
*
|
|
67
|
+
* 原先这里是一份独立的 `grab` 实现,漏了 `updated` / `tags` —— 两份解析器对同一份
|
|
68
|
+
* 损坏文件的"type 是空还是不是空"可能给出不同答案,而审计报告与注入排序都依赖它。
|
|
69
|
+
* 保留本函数只为**导出面**不变(审计与工具都在用);行为差异由 shared-parity 对拍守着。
|
|
67
70
|
*/
|
|
68
71
|
export function parseFrontmatterLite(content) {
|
|
69
|
-
const
|
|
70
|
-
if (!m) return { name: '', description: '', type: '', hasFrontmatter: false }
|
|
71
|
-
const body = m[1]
|
|
72
|
-
const grab = (key) => {
|
|
73
|
-
const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
|
|
74
|
-
return hit ? hit[1].replace(/^["']|["']$/g, '').trim() : ''
|
|
75
|
-
}
|
|
76
|
-
const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
|
|
77
|
-
const type = grab('type') || (nested ? nested[1] : '')
|
|
72
|
+
const fm = parseFrontmatter(content)
|
|
78
73
|
return {
|
|
79
|
-
name:
|
|
80
|
-
description:
|
|
81
|
-
type: String(type).trim(),
|
|
82
|
-
hasFrontmatter:
|
|
74
|
+
name: fm.name,
|
|
75
|
+
description: fm.description,
|
|
76
|
+
type: String(fm.type).trim(),
|
|
77
|
+
hasFrontmatter: hasFrontmatter(content),
|
|
83
78
|
}
|
|
84
79
|
}
|
|
85
80
|
|
package/lib/autodream/prompt.js
CHANGED
|
@@ -13,12 +13,7 @@
|
|
|
13
13
|
* 那段文字会发给每一个使用者的模型;把作者的记忆主题编进去就是泄漏
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
function today(ms) {
|
|
18
|
-
const d = new Date(ms)
|
|
19
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
20
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
21
|
-
}
|
|
16
|
+
import { stampDate } from '../shared/stamp.js'
|
|
22
17
|
|
|
23
18
|
/**
|
|
24
19
|
* 系统提示:身份 + 记忆系统的约定 + 这一趟的权限边界。
|
|
@@ -92,7 +87,7 @@ export function buildAutodreamUserPrompt(opts) {
|
|
|
92
87
|
|
|
93
88
|
return `# Autodream:一次记忆整理
|
|
94
89
|
|
|
95
|
-
今天:${
|
|
90
|
+
今天:${stampDate(now)}
|
|
96
91
|
记忆目录:\`${memoryDir}\`
|
|
97
92
|
距上次整理:${hoursSince === 0 ? '(没有记录,当成第一次)' : hoursSince.toFixed(1) + ' 小时'}
|
|
98
93
|
期间有更新的会话数:${sessionCount}
|
package/lib/autodream/tools.js
CHANGED
|
@@ -30,6 +30,8 @@ import { createHash } from 'node:crypto'
|
|
|
30
30
|
import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './audit.js'
|
|
31
31
|
import { archiveMemory } from '../memory/archive.js'
|
|
32
32
|
import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
|
|
33
|
+
import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
|
|
34
|
+
import { stampHuman as localStamp } from '../shared/stamp.js'
|
|
33
35
|
import { writeFileAtomic, warnLog } from '../memory/util.js'
|
|
34
36
|
import { buildConflict, writeConflict } from '../memory/conflicts.js'
|
|
35
37
|
import { memoryPathOf } from '../memory/paths.js'
|
|
@@ -42,17 +44,6 @@ const READ_LIMIT = 20000
|
|
|
42
44
|
const LIST_DESC_CHARS = 70
|
|
43
45
|
/** search_sessions 单次返回上限(字符)。 */
|
|
44
46
|
const SEARCH_LIMIT = 8000
|
|
45
|
-
/** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
|
|
46
|
-
const INDEX_FILE = 'memory.md'
|
|
47
|
-
/**
|
|
48
|
-
* 非记忆条目:不进 list_memory。
|
|
49
|
-
*
|
|
50
|
-
* session-log.md 是追加型流水,不是一条记忆 —— 列出来既
|
|
51
|
-
* 占掉模型一大块注意力,又诱导它去「整理」一份流水账。冒烟测试
|
|
52
|
-
* 逮到的就是这条:它出现在了 list_memory 的输出里。
|
|
53
|
-
*/
|
|
54
|
-
const NON_ENTRY_FILES = new Set(['session-log.md'])
|
|
55
|
-
|
|
56
47
|
/**
|
|
57
48
|
* 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
|
|
58
49
|
*
|
|
@@ -92,11 +83,7 @@ async function resolveRealName(memoryDir, base) {
|
|
|
92
83
|
return hit ? hit.name : base
|
|
93
84
|
}
|
|
94
85
|
|
|
95
|
-
/** ISO 时间 → 本地 `YYYY-MM-DD HH:mm
|
|
96
|
-
const d = new Date(ms)
|
|
97
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
98
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
99
|
-
}
|
|
86
|
+
/** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(唯一实现在 shared/stamp.js)。 */
|
|
100
87
|
|
|
101
88
|
/** 距今多少天,给模型一个人话尺度。 */
|
|
102
89
|
function ageDays(ms) {
|
|
@@ -118,7 +105,7 @@ async function scanEntries(memoryDir, ctx) {
|
|
|
118
105
|
}
|
|
119
106
|
const reservedIds = await reservedIdentitySet(memoryDir)
|
|
120
107
|
const names = entries
|
|
121
|
-
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') &&
|
|
108
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && e.name.toLowerCase() !== SESSION_LOG_KEY)
|
|
122
109
|
.map((e) => e.name)
|
|
123
110
|
const out = []
|
|
124
111
|
for (const name of names) {
|
|
@@ -141,7 +128,7 @@ async function scanEntries(memoryDir, ctx) {
|
|
|
141
128
|
out.push({
|
|
142
129
|
file: name,
|
|
143
130
|
// 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
|
|
144
|
-
type: name.toLowerCase() ===
|
|
131
|
+
type: name.toLowerCase() === INDEX_FILE_KEY ? '索引' : meta.type || 'reference',
|
|
145
132
|
bytes: gate.size,
|
|
146
133
|
mtimeMs: gate.mtimeMs,
|
|
147
134
|
title: (h1 ? h1[1] : meta.name || '').trim(),
|
package/lib/autodream/util.js
CHANGED
|
@@ -9,19 +9,8 @@ import { join } from 'node:path'
|
|
|
9
9
|
|
|
10
10
|
export const clampNum = (v, lo, hi, dflt) => (Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
const d = new Date(ms)
|
|
15
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
16
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** 本地时间戳,给人看。 */
|
|
20
|
-
export function stampHuman(ms) {
|
|
21
|
-
const d = new Date(ms)
|
|
22
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
23
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
24
|
-
}
|
|
12
|
+
import { stampCompact, stampHuman } from '../shared/stamp.js'
|
|
13
|
+
export { stampCompact, stampHuman }
|
|
25
14
|
|
|
26
15
|
/**
|
|
27
16
|
* 运行 id:`YYYYMMDD-HHmmss-<4 位>`。
|
package/lib/client.js
CHANGED
|
@@ -22,13 +22,68 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
let react = require("react");
|
|
23
23
|
const h = react.createElement;
|
|
24
24
|
|
|
25
|
+
// ── 共享基元(1.4.0):跨屏复用的小东西只有这一份 ─────────────
|
|
26
|
+
// 这些名字原本在 10 / 20 / 50 三个分片各写一遍(isStr/isNum/strSchema/parseSchema/
|
|
27
|
+
// unwrap/fmtSize/hexA,以及类型词表 3 份)。分片共享同一个 factory 作用域,
|
|
28
|
+
// 所以收敛到本片之后,后面的分片直接用、不再各写一份。
|
|
29
|
+
const isStr = (v) => { if (typeof v !== "string") throw new Error("expected string"); };
|
|
30
|
+
const isBool = (v) => { if (typeof v !== "boolean") throw new Error("expected boolean"); };
|
|
31
|
+
const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
|
|
32
|
+
function parseSchema(shape) {
|
|
33
|
+
return {
|
|
34
|
+
parse(value) {
|
|
35
|
+
if (value === null || typeof value !== "object") throw new Error("sage-mem: invalid payload");
|
|
36
|
+
for (const [key, check] of Object.entries(shape)) {
|
|
37
|
+
if (!(key in value)) throw new Error("sage-mem: missing field " + key);
|
|
38
|
+
check(value[key]);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const strSchema = { parse(v) { isStr(v); return v; } };
|
|
45
|
+
|
|
46
|
+
/** 拆 typert remote 的 {ok, value} 信封。 */
|
|
47
|
+
function unwrap(result) {
|
|
48
|
+
return result && result.ok ? result.value : result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 字节数 → 人话。三个档位对记忆文件足够(单条上限 512 KB)。 */
|
|
52
|
+
function fmtSize(n) {
|
|
53
|
+
if (n < 1024) return n + " B";
|
|
54
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
55
|
+
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** #rrggbb + alpha → rgba()。星图的辉光/底盘用。 */
|
|
59
|
+
function hexA(hex, a) {
|
|
60
|
+
const n = parseInt(hex.slice(1), 16);
|
|
61
|
+
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 类型词表(唯一定义) ──
|
|
65
|
+
// 文件列表的排序 / 筛选 / 徽章、星图的 kind 标签与配色,全部从这里取。
|
|
66
|
+
const TYPE_ORDER = ["user", "feedback", "project", "reference"];
|
|
67
|
+
const TYPE_SORT = ["user", "feedback", "project", "reference", "special"];
|
|
68
|
+
const TYPE_LABEL = { user: "用户", feedback: "反馈", project: "项目", reference: "参考", special: "特殊" };
|
|
69
|
+
const TYPE_META = {
|
|
70
|
+
user: ["👤", TYPE_LABEL.user],
|
|
71
|
+
feedback: ["📌", TYPE_LABEL.feedback],
|
|
72
|
+
project: ["📁", TYPE_LABEL.project],
|
|
73
|
+
reference: ["🔗", TYPE_LABEL.reference],
|
|
74
|
+
};
|
|
75
|
+
// 星图配色:深色档(夜空)与浅色档(暖纸 + 矿物颜料)各一套,色相错开才分得清类型。
|
|
76
|
+
const TYPE_COLOR_DARK = {
|
|
77
|
+
user: "#e0c07a", feedback: "#7ec8da", project: "#ac9edc", reference: "#92ceaa", special: "#bcc3d0",
|
|
78
|
+
};
|
|
79
|
+
const TYPE_COLOR_LIGHT = {
|
|
80
|
+
user: "#a4652c", feedback: "#3a6f8f", project: "#6a5b8f", reference: "#4a7c63", special: "#5b5750",
|
|
81
|
+
};
|
|
25
82
|
const StarmapRuntime = (function (require, _react) {
|
|
26
83
|
let react = _react;
|
|
27
84
|
const h = react.createElement;
|
|
28
85
|
// ── 手写 strict codec(浏览器无 zod,typert 只要求 parse 方法)──
|
|
29
|
-
|
|
30
|
-
const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
|
|
31
|
-
const strSchema = { parse(v) { isStr(v); return v; } };
|
|
86
|
+
// isStr / isNum / strSchema 来自 05-shared.js(这个 IIFE 在 factory 作用域内,看得到)。
|
|
32
87
|
const starSchema = {
|
|
33
88
|
parse(v) {
|
|
34
89
|
if (v === null || typeof v !== "object") throw new Error("invalid star");
|
|
@@ -81,11 +136,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
81
136
|
],
|
|
82
137
|
};
|
|
83
138
|
|
|
84
|
-
/** 拆 typert remote 的 {ok, value} 信封。 */
|
|
85
|
-
function unwrap(result) {
|
|
86
|
-
return result && result.ok ? result.value : result;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
139
|
/** 错误详情尽量展开,避免 "[object Object]" 式黑洞。 */
|
|
90
140
|
function detail(e) {
|
|
91
141
|
if (!e) return "未知错误";
|
|
@@ -114,15 +164,10 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
114
164
|
// 深空画布上够辨认,又不抢眼。
|
|
115
165
|
//
|
|
116
166
|
// 两档取值:`color` = 深色档(夜空画布),`light` = 浅色档(纸本设色)。
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
const KIND_META = {
|
|
120
|
-
|
|
121
|
-
feedback: { label: "反馈", color: "#7ec8da", light: "#3a6f8f" },
|
|
122
|
-
project: { label: "项目", color: "#ac9edc", light: "#6a5b8f" },
|
|
123
|
-
reference: { label: "参考", color: "#92ceaa", light: "#4a7c63" },
|
|
124
|
-
special: { label: "特殊", color: "#bcc3d0", light: "#5b5750" },
|
|
125
|
-
};
|
|
167
|
+
// 类型标签与配色来自 05-shared.js 的唯一定义;这里只是把它们组装成星图用的 meta。
|
|
168
|
+
const KIND_META = {};
|
|
169
|
+
for (const k of TYPE_SORT) KIND_META[k] = { label: TYPE_LABEL[k], color: TYPE_COLOR_DARK[k], light: TYPE_COLOR_LIGHT[k] };
|
|
170
|
+
|
|
126
171
|
/**
|
|
127
172
|
* 宿主主题挂钩 —— 与 ui-theme 同一处:body[data-ds-dark-theme] 存在即深色档。
|
|
128
173
|
*/
|
|
@@ -180,16 +225,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
180
225
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
181
226
|
}
|
|
182
227
|
|
|
183
|
-
function hexA(hex, a) {
|
|
184
|
-
const n = parseInt(hex.slice(1), 16);
|
|
185
|
-
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function fmtBytes(n) {
|
|
189
|
-
if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
|
|
190
|
-
return n + " B";
|
|
191
|
-
}
|
|
192
|
-
|
|
193
228
|
function fmtDate(ms) {
|
|
194
229
|
if (!ms) return "";
|
|
195
230
|
const d = new Date(ms);
|
|
@@ -391,39 +426,12 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
391
426
|
return blocks;
|
|
392
427
|
}
|
|
393
428
|
|
|
394
|
-
// ── overlay 开合的模块级 store(footer 按钮与 overlay 共享)──
|
|
395
|
-
const uiStore = { open: false, subs: new Set() };
|
|
396
429
|
/** client context 由 apply 存入;Slot 组件经 props 或此兜底获取。 */
|
|
397
430
|
let appCtx = null;
|
|
398
|
-
function setOpen(v) {
|
|
399
|
-
uiStore.open = !!v;
|
|
400
|
-
uiStore.subs.forEach((fn) => fn(uiStore.open));
|
|
401
|
-
}
|
|
402
|
-
function useOpen() {
|
|
403
|
-
const s = react.useState(uiStore.open);
|
|
404
|
-
react.useEffect(() => {
|
|
405
|
-
const fn = (v) => s[1](v);
|
|
406
|
-
uiStore.subs.add(fn);
|
|
407
|
-
return () => { uiStore.subs.delete(fn); };
|
|
408
|
-
}, []);
|
|
409
|
-
return s[0];
|
|
410
|
-
}
|
|
411
|
-
|
|
412
431
|
// ── 样式(data-plugin-css 约定,幂等注入)──
|
|
413
432
|
const CSS = [
|
|
414
|
-
".smap-footer-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1,transparent);background:transparent;color:var(--dsw-alias-label-secondary,inherit);border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap;}",
|
|
415
|
-
".smap-fab{position:fixed;right:0;top:15%;transform:translateY(-50%);z-index:900;width:36px;height:64px;border-radius:12px 0 0 12px;border:1px solid var(--dsw-alias-border-l1);border-right:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary);font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:.5;pointer-events:auto;transition:opacity .15s,background .15s,width .15s;box-shadow:-4px 0 18px rgba(0,0,0,.35);font-family:inherit;}",
|
|
416
|
-
".smap-fab:hover{opacity:1;width:46px;background:var(--dsw-alias-bg-layer-1);}",
|
|
417
|
-
".smap-footer-btn:hover{color:var(--dsw-alias-label-primary,inherit);border-color:var(--dsw-alias-border-l2,#8886);}",
|
|
418
|
-
".smap-footer-icon{font-size:13px;line-height:1;}",
|
|
419
|
-
".smap-veil{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.45));backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;pointer-events:auto;animation:smap-fade-in .18s ease-out;}",
|
|
420
|
-
".smap-modal{width:min(980px,94vw);height:min(680px,92vh);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.55);padding:16px 18px;display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;animation:smap-pop-in .22s cubic-bezier(.2,.9,.3,1.15);}",
|
|
421
|
-
"@keyframes smap-fade-in{from{opacity:0}to{opacity:1}}",
|
|
422
|
-
"@keyframes smap-pop-in{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:none}}",
|
|
423
433
|
".smap-card{background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;flex:1;min-height:0;}",
|
|
424
434
|
".smap-head{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}",
|
|
425
|
-
".smap-title{font-size:15px;font-weight:600;letter-spacing:.06em;}",
|
|
426
|
-
".smap-sub{font-size:11px;color:var(--dsw-alias-label-secondary);}",
|
|
427
435
|
".smap-spacer{flex:1;}",
|
|
428
436
|
".smap-search{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 9px;outline:none;width:160px;font-family:inherit;}",
|
|
429
437
|
".smap-search:focus{border-color:var(--dsw-alias-border-l2);}",
|
|
@@ -1358,7 +1366,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1358
1366
|
if (s.mtimeMs > newest) newest = s.mtimeMs;
|
|
1359
1367
|
}
|
|
1360
1368
|
legendKids.push(h("span", { key: "count", className: "smap-count" },
|
|
1361
|
-
viewStars.length + " 条 · " +
|
|
1369
|
+
viewStars.length + " 条 · " + fmtSize(totalBytes) + (newest ? " · 最近更新 " + fmtDate(newest) : "") +
|
|
1362
1370
|
(showArchive && archivedN ? " · 档案馆 " + archivedN + " 颗暗星" : "") +
|
|
1363
1371
|
" · 单击看" + (showArchive && archivedN ? "详情" : "全文")));
|
|
1364
1372
|
|
|
@@ -1367,7 +1375,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1367
1375
|
const kids = [
|
|
1368
1376
|
h("div", { key: "t", className: "smap-tip-title" }, hover.star.title),
|
|
1369
1377
|
h("div", { key: "k", className: "smap-tip-kind" },
|
|
1370
|
-
meta.label + " · " + hover.star.file + " · " +
|
|
1378
|
+
meta.label + " · " + hover.star.file + " · " + fmtSize(hover.star.bytes)),
|
|
1371
1379
|
];
|
|
1372
1380
|
if (hover.star.archived === true) {
|
|
1373
1381
|
// 悬浮也要说清它是档案馆里的:暗星外表已经不同,别让人猜。
|
|
@@ -1391,7 +1399,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1391
1399
|
h("button", { className: "smap-btn", onClick: () => setSelected(null), title: "关闭" }, "×")),
|
|
1392
1400
|
h("div", { key: "meta", className: "smap-panel-meta" },
|
|
1393
1401
|
h("span", { className: "smap-meta-dot", style: { background: kindColor(meta) } }),
|
|
1394
|
-
meta.label + " · " + selected.star.file + " · " +
|
|
1402
|
+
meta.label + " · " + selected.star.file + " · " + fmtSize(selected.star.bytes) +
|
|
1395
1403
|
// 归档星的 mtime 是「当年写下」的时间,跟「什么时候收起来的」不是一回事 ——
|
|
1396
1404
|
// 这里只留「已归档」,具体时间在下面的归档块里给。
|
|
1397
1405
|
(selected.star.archived === true
|
|
@@ -1494,42 +1502,11 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1494
1502
|
body);
|
|
1495
1503
|
}
|
|
1496
1504
|
|
|
1497
|
-
// ── 全屏 overlay ─────────────────────────────────────────────
|
|
1498
|
-
function StarmapOverlay() {
|
|
1499
|
-
const open = useOpen();
|
|
1500
|
-
react.useEffect(() => {
|
|
1501
|
-
if (!open) return undefined;
|
|
1502
|
-
const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
|
|
1503
|
-
document.addEventListener("keydown", onKey);
|
|
1504
|
-
return () => document.removeEventListener("keydown", onKey);
|
|
1505
|
-
}, [open]);
|
|
1506
|
-
if (!open) return null;
|
|
1507
|
-
return h("div", {
|
|
1508
|
-
className: "smap-veil",
|
|
1509
|
-
onMouseDown: (e) => { if (e.target === e.currentTarget) setOpen(false); },
|
|
1510
|
-
},
|
|
1511
|
-
h("div", { className: "smap-modal" },
|
|
1512
|
-
h(StarMap, { ctx: appCtx, onClose: () => setOpen(false) })));
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
// ── 窗口右缘浮标(唯一入口,不绑实例/会话/Run 卡片)────────────
|
|
1516
|
-
function StarmapEdgeTab() {
|
|
1517
|
-
const open = useOpen();
|
|
1518
|
-
if (open) return null;
|
|
1519
|
-
return h("button", {
|
|
1520
|
-
type: "button",
|
|
1521
|
-
className: "smap-fab",
|
|
1522
|
-
onClick: () => setOpen(true),
|
|
1523
|
-
title: "记忆星图 — 把 sage-mem 的记忆画成一片星空",
|
|
1524
|
-
"aria-label": "记忆星图",
|
|
1525
|
-
}, "✦");
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
1505
|
const inject = ["slots", "remote"];
|
|
1529
1506
|
|
|
1530
1507
|
/**
|
|
1531
1508
|
* Client 插件:$mount strict remote contribution(第三方 remote 不在
|
|
1532
|
-
* 宿主的固定 remote
|
|
1509
|
+
* 宿主的固定 remote 清单里,必须自己挂),再注册星图子槽(B 方案起不再有右缘浮标与全屏 overlay)。
|
|
1533
1510
|
*/
|
|
1534
1511
|
async function apply(ctx) {
|
|
1535
1512
|
appCtx = ctx;
|
|
@@ -1559,22 +1536,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1559
1536
|
|
|
1560
1537
|
|
|
1561
1538
|
// ── 手写 strict codec(浏览器无 zod,typert 只要求 parse 方法)──
|
|
1562
|
-
|
|
1563
|
-
return {
|
|
1564
|
-
parse(value) {
|
|
1565
|
-
if (value === null || typeof value !== "object") throw new Error("sage-mem: invalid payload");
|
|
1566
|
-
for (const [key, check] of Object.entries(shape)) {
|
|
1567
|
-
if (!(key in value)) throw new Error("sage-mem: missing field " + key);
|
|
1568
|
-
check(value[key]);
|
|
1569
|
-
}
|
|
1570
|
-
return value;
|
|
1571
|
-
},
|
|
1572
|
-
};
|
|
1573
|
-
}
|
|
1574
|
-
const isStr = (v) => { if (typeof v !== "string") throw new Error("expected string"); };
|
|
1575
|
-
const isBool = (v) => { if (typeof v !== "boolean") throw new Error("expected boolean"); };
|
|
1576
|
-
const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
|
|
1577
|
-
const strSchema = { parse(v) { isStr(v); return v; } };
|
|
1539
|
+
// 基元(parseSchema / isStr / isBool / isNum / strSchema)在 05-shared.js。
|
|
1578
1540
|
const fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum });
|
|
1579
1541
|
const listSchema = { parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
|
|
1580
1542
|
const readSchema = parseSchema({ "name": isStr, "content": isStr });
|
|
@@ -2072,10 +2034,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2072
2034
|
// ── 工具行 + 工具面板(体检 / 归档候选 / 保留名原样编辑)──
|
|
2073
2035
|
// 三个新入口收在一行**轻量**文字按钮里:不跟主工具栏抢重量级,也不各占一块版面。
|
|
2074
2036
|
// 面板一次只开一个(Section 里一个 tool 状态),否则文件列表会被顶到屏幕外。
|
|
2075
|
-
".smem-tools{display:flex;align-items:center;gap:4px;flex-wrap:wrap;}",
|
|
2076
|
-
".smem-tool-btn{border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer;padding:2px 8px;border-radius:6px;}",
|
|
2077
|
-
".smem-tool-btn:hover{color:var(--dsw-alias-label-primary);}",
|
|
2078
|
-
'.smem-tool-btn[data-on="1"]{color:var(--dsw-alias-brand-primary,#4d76e6);background:var(--dsw-alias-bg-layer-2);}',
|
|
2079
2037
|
// 工具抽屉:关着时**零占位**(工具栏里只有一个「工具 ▾」),点开才占版面。
|
|
2080
2038
|
// ⚠️ 可发现性不靠抽屉本身,靠头部带常显的「体检 N 个问题」徽标 —— 那是整个
|
|
2081
2039
|
// 抽屉方案能成立的前提(否则三个能力就退回「藏起来」了)。
|
|
@@ -2207,18 +2165,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2207
2165
|
".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
|
|
2208
2166
|
].join("\n");
|
|
2209
2167
|
|
|
2210
|
-
const TYPE_META = {
|
|
2211
|
-
user: ["👤", "用户"],
|
|
2212
|
-
feedback: ["📌", "反馈"],
|
|
2213
|
-
project: ["📁", "项目"],
|
|
2214
|
-
reference: ["🔗", "参考"],
|
|
2215
|
-
};
|
|
2216
|
-
const TYPE_ORDER = ["user", "feedback", "project", "reference"];
|
|
2217
|
-
// 筛选条上的显示名:emoji 在筛选条里太吵,只用文字;special 是星图的兜底类,
|
|
2218
|
-
// 文件列表里出现它说明 frontmatter 写了 type: special。
|
|
2219
|
-
const TYPE_LABEL = { user: "用户", feedback: "反馈", project: "项目", reference: "参考", special: "特殊" };
|
|
2220
|
-
const TYPE_SORT = ["user", "feedback", "project", "reference", "special"];
|
|
2221
|
-
|
|
2222
2168
|
/**
|
|
2223
2169
|
* 把输入框里的一串标签切成数组。
|
|
2224
2170
|
*
|
|
@@ -2467,17 +2413,6 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2467
2413
|
tagLine + "\nmetadata:\n node_type: memory\n type: " + type + "\n---\n\n";
|
|
2468
2414
|
}
|
|
2469
2415
|
|
|
2470
|
-
function fmtSize(n) {
|
|
2471
|
-
if (n < 1024) return n + " B";
|
|
2472
|
-
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
2473
|
-
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
/** 拆 typert remote 的 {ok, value} 信封。 */
|
|
2477
|
-
function unwrap(result) {
|
|
2478
|
-
return result && result.ok ? result.value : result;
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
2416
|
function Chip(props) {
|
|
2482
2417
|
return h("button", {
|
|
2483
2418
|
type: "button",
|
|
@@ -2766,7 +2701,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2766
2701
|
standalone ? null : toolClose(props),
|
|
2767
2702
|
]),
|
|
2768
2703
|
r.problems
|
|
2769
|
-
? h("div", {
|
|
2704
|
+
? h("div", { key: "secs" }, [
|
|
2770
2705
|
section("索引悬空(索引里有、文件没了)", r.dangling, (x) => x.file),
|
|
2771
2706
|
section("漏索引(文件在、索引里没有)", r.unlisted, (x) => x.file),
|
|
2772
2707
|
section("type 与前缀对不上", r.typeMismatch, (x) => x.file + " · type=" + x.type + ",文件名前缀=" + x.prefix),
|
|
@@ -3252,7 +3187,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3252
3187
|
subtab("settings", "设置"),
|
|
3253
3188
|
subtab("records", "记录", String(snaps.length + runs.length + reports.length)),
|
|
3254
3189
|
]),
|
|
3255
|
-
segOf === "run" ? h("div", {
|
|
3190
|
+
segOf === "run" ? h("div", { key: "seg-run" }, [
|
|
3256
3191
|
h("div", { className: "smem-autodream-sec", key: "top" }, [
|
|
3257
3192
|
h("div", { className: "smem-autodream-row", key: "r" }, [
|
|
3258
3193
|
h("button", {
|
|
@@ -3376,7 +3311,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3376
3311
|
: null,
|
|
3377
3312
|
]),
|
|
3378
3313
|
]) : null,
|
|
3379
|
-
segOf === "settings" ? h("div", {
|
|
3314
|
+
segOf === "settings" ? h("div", { key: "seg-set" }, [
|
|
3380
3315
|
// 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
|
|
3381
3316
|
// 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
|
|
3382
3317
|
// 同一件事只在一处设定,这里不再重复摆一份。
|
|
@@ -3477,7 +3412,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3477
3412
|
]) : null,
|
|
3478
3413
|
// ── 回滚点(新)──────────────────────────────────────────────
|
|
3479
3414
|
// 只改这一个文件、只用既有 h() 写法;数据全走 remote.autodream。
|
|
3480
|
-
segOf === "records" ? h("div", {
|
|
3415
|
+
segOf === "records" ? h("div", { key: "seg-rec" }, [
|
|
3481
3416
|
h("div", { className: "smem-autodream-sec", key: "snap" }, [
|
|
3482
3417
|
h("div", { className: "smem-autodream-row", key: "h" }, [
|
|
3483
3418
|
h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "回滚点"),
|
|
@@ -3706,7 +3641,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3706
3641
|
});
|
|
3707
3642
|
}
|
|
3708
3643
|
|
|
3709
|
-
return h("div", {
|
|
3644
|
+
return h("div", { }, [
|
|
3710
3645
|
// ── ① 它做什么(这一屏存在的理由写在最前面)──
|
|
3711
3646
|
h("div", { className: "smem-autodream-sec", key: "what" }, [
|
|
3712
3647
|
h("div", { className: "smem-autodream-row", key: "h" }, [
|
|
@@ -3898,7 +3833,8 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
3898
3833
|
const rawBusy = sRawBusy[0], setRawBusy = sRawBusy[1];
|
|
3899
3834
|
const sRawLoading = react.useState(false);
|
|
3900
3835
|
const rawLoading = sRawLoading[0], setRawLoading = sRawLoading[1];
|
|
3901
|
-
|
|
3836
|
+
// renderSlot:shell 会经 props 传进来(见 80-apply 的 children 声明),
|
|
3837
|
+
// 但本插件改用并列 tab 渲染星图,不再调用它 —— 声明保留给 shell,这里不取。
|
|
3902
3838
|
|
|
3903
3839
|
function load() {
|
|
3904
3840
|
setLoading(true);
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import { AutodreamEngine } from './autodream.js'
|
|
18
18
|
import { auditMemoryDir } from './autodream/audit.js'
|
|
19
19
|
import { DEFAULT_CONFIG } from './autodream/config.js'
|
|
20
|
-
import { 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'
|
|
20
|
+
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'
|
|
21
|
+
import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
|
|
21
22
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
22
23
|
import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
23
24
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
@@ -30,7 +31,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
|
30
31
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
31
32
|
import { join } from 'node:path'
|
|
32
33
|
import { checkUpdate as checkUpdateImpl } from './update.js'
|
|
33
|
-
import { autodreamConfigSnapshot, clampInt,
|
|
34
|
+
import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
|
|
34
35
|
import { createRecallInjector, createSessionInjectState } from './recall/index.js'
|
|
35
36
|
import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
|
|
36
37
|
|
|
@@ -420,7 +421,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
420
421
|
const pending = pendingLimits(file)
|
|
421
422
|
const reservedExtra = await readReservedFile()
|
|
422
423
|
// 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
|
|
423
|
-
const activeExtra = [...RESERVED_FILES].filter((n) => n !==
|
|
424
|
+
const activeExtra = [...RESERVED_FILES].filter((n) => n !== INDEX_FILE_KEY && n !== SESSION_LOG_KEY)
|
|
424
425
|
const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
|
|
425
426
|
const restartRequired =
|
|
426
427
|
JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
|
|
@@ -429,7 +430,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
429
430
|
pendingLimits: pending,
|
|
430
431
|
restartRequired,
|
|
431
432
|
// 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
|
|
432
|
-
reserved: [...new Set([
|
|
433
|
+
reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
|
|
433
434
|
reservedExtra,
|
|
434
435
|
}
|
|
435
436
|
}
|
|
@@ -465,7 +466,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
465
466
|
const cleaned = []
|
|
466
467
|
for (const item of p.reserved) {
|
|
467
468
|
const s = String(item ?? '').trim()
|
|
468
|
-
if (!
|
|
469
|
+
if (!isPlainMdName(s) || s.startsWith('..')) {
|
|
469
470
|
return { ok: false, error: `sage-mem: invalid reserved file name: ${String(item)}` }
|
|
470
471
|
}
|
|
471
472
|
if (!cleaned.includes(s)) cleaned.push(s)
|
package/lib/memory/config.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
|
+
import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
|
|
11
12
|
|
|
12
13
|
export const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
|
|
13
14
|
/**
|
|
@@ -29,6 +30,19 @@ export function envInt(name, dflt, lo, hi) {
|
|
|
29
30
|
return Math.max(lo, Math.min(hi, Math.round(n)))
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 面板可改的 5 项注入上限 —— **唯一定义**(值 + 边界)。
|
|
35
|
+
* 面板(\u0060lib/settings.js\u0060)、组合根、环境变量三处都从这里取,不再各写一份。
|
|
36
|
+
*/
|
|
37
|
+
export const LIMIT_SPECS = {
|
|
38
|
+
maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
|
|
39
|
+
maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
|
|
40
|
+
maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
|
|
41
|
+
maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
|
|
42
|
+
staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
|
|
43
|
+
}
|
|
44
|
+
const fromSpec = (spec) => envInt(spec.env, spec.dflt, spec.lo, spec.hi)
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* 一次注入最多带几条记忆。
|
|
34
48
|
*
|
|
@@ -37,10 +51,10 @@ export function envInt(name, dflt, lo, hi) {
|
|
|
37
51
|
* 字符的上下文。**这个数字该由实测决定**,所以留了 SAGE_MEM_MAX_RESULTS 覆写口
|
|
38
52
|
* (比较两档的命中率与 token 账单,别拍脑袋)。
|
|
39
53
|
*/
|
|
40
|
-
export const MAX_RESULTS =
|
|
54
|
+
export const MAX_RESULTS = fromSpec(LIMIT_SPECS.maxResults)
|
|
41
55
|
|
|
42
56
|
/** 单条记忆注入的字符上限。 */
|
|
43
|
-
export const MAX_CHARS_PER_FILE =
|
|
57
|
+
export const MAX_CHARS_PER_FILE = fromSpec(LIMIT_SPECS.maxChars)
|
|
44
58
|
|
|
45
59
|
/**
|
|
46
60
|
* baseline 名额。
|
|
@@ -49,7 +63,7 @@ export const MAX_CHARS_PER_FILE = envInt('SAGE_MEM_MAX_CHARS', 1500, 200, 20000)
|
|
|
49
63
|
* 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
|
|
50
64
|
* 排序见下面「按显式优先级排,不按文件名」那段。
|
|
51
65
|
*/
|
|
52
|
-
export const MAX_BASELINE =
|
|
66
|
+
export const MAX_BASELINE = fromSpec(LIMIT_SPECS.maxBaseline)
|
|
53
67
|
|
|
54
68
|
/**
|
|
55
69
|
* 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
|
|
@@ -63,7 +77,7 @@ export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
|
|
|
63
77
|
* 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
|
|
64
78
|
* 覆写口,量过再定。
|
|
65
79
|
*/
|
|
66
|
-
export const MAX_SESSION_BYTES =
|
|
80
|
+
export const MAX_SESSION_BYTES = fromSpec(LIMIT_SPECS.maxSessionBytes)
|
|
67
81
|
|
|
68
82
|
/**
|
|
69
83
|
* 状态卡预留占总预算的比例,以及它的上下夹逼区间。
|
|
@@ -94,7 +108,7 @@ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
|
|
|
94
108
|
* 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
|
|
95
109
|
* 本身会让它显得更权威、而不是更可疑。
|
|
96
110
|
*/
|
|
97
|
-
export const STALE_DAYS =
|
|
111
|
+
export const STALE_DAYS = fromSpec(LIMIT_SPECS.staleDays)
|
|
98
112
|
|
|
99
113
|
/**
|
|
100
114
|
* 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
|
|
@@ -144,13 +158,13 @@ export function readExtraReserved() {
|
|
|
144
158
|
if (!Array.isArray(arr)) return []
|
|
145
159
|
return arr
|
|
146
160
|
.map((s) => String(s).trim().toLowerCase())
|
|
147
|
-
.filter((s) =>
|
|
161
|
+
.filter((s) => isPlainMdName(s) && !s.startsWith('..'))
|
|
148
162
|
} catch {
|
|
149
163
|
return []
|
|
150
164
|
}
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
export const RESERVED_FILES = new Set([
|
|
167
|
+
export const RESERVED_FILES = new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...readExtraReserved()])
|
|
154
168
|
|
|
155
169
|
/**
|
|
156
170
|
* 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
|
|
@@ -161,8 +175,8 @@ export const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExt
|
|
|
161
175
|
* (要出现在列表与星图里、要能读),只是不许从表单写/删。
|
|
162
176
|
*/
|
|
163
177
|
export const NON_ENTRY_FILES = new Set([
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
INDEX_FILE_KEY,
|
|
179
|
+
SESSION_LOG_KEY,
|
|
166
180
|
])
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -173,4 +187,4 @@ export const NON_ENTRY_FILES = new Set([
|
|
|
173
187
|
* 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
|
|
174
188
|
* 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
|
|
175
189
|
*/
|
|
176
|
-
export
|
|
190
|
+
export { MAX_FILE_BYTES } from '../shared/limits.js'
|
|
@@ -29,6 +29,11 @@ export function parseTags(body) {
|
|
|
29
29
|
return []
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** 这份文件有没有 frontmatter 块(审计用;与 parseFrontmatter 的匹配规则同一份)。 */
|
|
33
|
+
export function hasFrontmatter(content) {
|
|
34
|
+
return typeof content === 'string' && /^---\r?\n[\s\S]*?\r?\n---/.test(content)
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
/** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
|
|
33
38
|
export function parseFrontmatter(content) {
|
|
34
39
|
// 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
|
package/lib/memory/index-file.js
CHANGED
|
@@ -12,9 +12,10 @@ import { readFile, readdir } from 'node:fs/promises'
|
|
|
12
12
|
import { basename, join } from 'node:path'
|
|
13
13
|
import { parseFrontmatter } from './frontmatter.js'
|
|
14
14
|
import { nameKey } from './naming.js'
|
|
15
|
+
import { INDEX_FILE_KEY } from '../shared/md-name.js'
|
|
15
16
|
import { createWriteQueue, writeFileAtomic } from './util.js'
|
|
16
17
|
|
|
17
|
-
const INDEX_KEY =
|
|
18
|
+
const INDEX_KEY = INDEX_FILE_KEY
|
|
18
19
|
/** 条目里的描述上限:索引是清单,不是内容堆。 */
|
|
19
20
|
const DESC_MAX = 120
|
|
20
21
|
|
package/lib/memory/naming.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { NON_ENTRY_FILES, RESERVED_FILES } from './config.js'
|
|
6
|
+
import { isPlainMdName } from '../shared/md-name.js'
|
|
6
7
|
import { basename } from 'node:path'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -44,23 +45,9 @@ export function isMemoryEntry(name) {
|
|
|
44
45
|
* @returns 通过校验的原始文件名;非法返回 null
|
|
45
46
|
*/
|
|
46
47
|
export function safeName(raw) {
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (basename(raw) !== raw) return null
|
|
51
|
-
// `:` 在 NTFS 上是**交替数据流**的分隔符:`MEMORY.md:evil.md` 以 `.md` 结尾、能过下面
|
|
52
|
-
// 所有判据,落盘时却会写到索引的隐藏数据流上(`readdir` 看不见,只写流不报错)。
|
|
53
|
-
// 实测更糟:`writeFile('MEMORY.md:evil.md')` 会在 `rename` 处抛**未捕获**的 EINVAL ——
|
|
54
|
-
// 违背「拒绝一律返回 {ok:false}、不抛」这条契约。正常文件名不允许 `:`,直接拒。
|
|
55
|
-
if (raw.includes(':')) return null
|
|
56
|
-
// `\` 是 `:` 那条理由的另一半:**它是 Windows 的路径分隔符,在 Linux 上却只是普通字符**。
|
|
57
|
-
// 于是 `basename('..\\a.md')` 在 Windows 上得到 `a.md`(被上面那条判据拒掉),
|
|
58
|
-
// 在 Linux 上原样返回 `..\a.md`(整条通过)—— 同一份输入在两个平台上得出相反的合法性。
|
|
59
|
-
// 而记忆目录是会被同步 / 拷贝 / 备份还原的:**在 Linux 上合法落盘的名字,同步到 Windows
|
|
60
|
-
// 就是一个目录穿越**。判定必须与平台无关,所以无条件拒。
|
|
61
|
-
// (这条是 Linux CI 抓出来的:本机 Windows 上测试一直是绿的。)
|
|
62
|
-
if (raw.includes('\\')) return null
|
|
63
|
-
if (!/\.md$/i.test(raw)) return null
|
|
48
|
+
// 底线规则(单段 / .md / 无路径分隔符 / 无冒号)只有一份:shared/md-name.js。
|
|
49
|
+
// 这里额外的是**记忆条目**语义:索引与流水不是一条记忆。
|
|
50
|
+
if (!isPlainMdName(raw)) return null
|
|
64
51
|
if (isNonEntry(raw)) return null
|
|
65
52
|
return raw
|
|
66
53
|
}
|
package/lib/memory/scan.js
CHANGED
|
@@ -24,12 +24,7 @@ export const ARCHIVE_DIR_NAME = 'archive'
|
|
|
24
24
|
/** 归档区绝对路径。文件名不变,只换目录。 */
|
|
25
25
|
export const MEMORY_ARCHIVE_DIR = join(MEMORY_DIR, ARCHIVE_DIR_NAME)
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
export function localStamp(ms = Date.now()) {
|
|
29
|
-
const d = new Date(ms)
|
|
30
|
-
const p = (n) => String(n).padStart(2, '0')
|
|
31
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
32
|
-
}
|
|
27
|
+
export { stampHuman as localStamp } from '../shared/stamp.js'
|
|
33
28
|
|
|
34
29
|
/**
|
|
35
30
|
* 把一个值写成 YAML 标量。
|
package/lib/settings.js
CHANGED
|
@@ -5,35 +5,18 @@
|
|
|
5
5
|
* envInt 调用(lo/hi 不许在这里另立一套)。
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises'
|
|
8
|
-
import {
|
|
9
|
-
import { STATE_ROOT } from './memory/config.js'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { LIMIT_SPECS, STATE_ROOT } from './memory/config.js'
|
|
10
|
+
import { MAX_RAW_BYTES } from './shared/limits.js'
|
|
11
|
+
import { isPlainMdName } from './shared/md-name.js'
|
|
12
|
+
export { LIMIT_SPECS, MAX_RAW_BYTES }
|
|
10
13
|
import { DEFAULT_CONFIG } from './autodream/config.js'
|
|
11
14
|
import { readJson } from './autodream/util.js'
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
* 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
|
|
15
|
-
* 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
|
|
16
|
-
*/
|
|
17
|
-
export const LIMIT_SPECS = {
|
|
18
|
-
maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
|
|
19
|
-
maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
|
|
20
|
-
maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
|
|
21
|
-
maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
|
|
22
|
-
staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
/** 面板设置的落点:状态根下(与 autodream 的配置、台账同级)。 */
|
|
26
17
|
export const SETTINGS_FILE = join(STATE_ROOT, 'settings.json')
|
|
27
18
|
export const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
|
|
28
19
|
|
|
29
|
-
/**
|
|
30
|
-
* `writeRaw` 的体积上限(4 MB)。
|
|
31
|
-
*
|
|
32
|
-
* ⚠️ **故意不复用 `MAX_FILE_BYTES`(512 KB)**:保留名里就有 `session-log.md` 这种追加型
|
|
33
|
-
* 流水,它天然长得比单条记忆大得多(实测已 > 512 KB)。沿用小上限 = 用户永远存不回去。
|
|
34
|
-
*/
|
|
35
|
-
export const MAX_RAW_BYTES = 4 * 1024 * 1024
|
|
36
|
-
|
|
37
20
|
/** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
|
|
38
21
|
export const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
|
|
39
22
|
|
|
@@ -84,11 +67,8 @@ export async function readReservedFile() {
|
|
|
84
67
|
*/
|
|
85
68
|
export function rawName(raw) {
|
|
86
69
|
const s = typeof raw === 'string' ? raw : ''
|
|
87
|
-
if (!
|
|
88
|
-
|
|
89
|
-
// 索引的隐藏流上(`readdir` 看不见)。与 `safeName` 同一条理由,这里也要拒。
|
|
90
|
-
if (s.includes(':')) return null
|
|
91
|
-
return /\.md$/i.test(s) ? s : null
|
|
70
|
+
if (!isPlainMdName(s)) return null
|
|
71
|
+
return s
|
|
92
72
|
}
|
|
93
73
|
|
|
94
74
|
/** autodream 的实际配置(缺文件/坏文件回落默认值)—— 面板要显示真实策略,不能显示想象。 */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两个体积上限的**唯一**来源(零依赖,host / manifest / 引擎共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独抽:512 KB 那条同时出现在 `memory/config.js`、`typert.host.js` 的
|
|
5
|
+
* `fileContentSchema` 与注释里;4 MB 那条出现在设置工具与 manifest 的
|
|
6
|
+
* `rawFileContentSchema`。它们**必须相等** —— 传输层比写入层紧,用户就会收到
|
|
7
|
+
* "面板能存、remote 存不进去"的怪现象;比写入层松,则超限内容会穿过闸门。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 单条记忆的上限(512 KB)。`writeFile` / 读取闸门 / manifest 三处同一把尺子。 */
|
|
11
|
+
export const MAX_FILE_BYTES = 512 * 1024
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 保留名原样读写的上限(4 MB)。**故意不复用 512 KB**:
|
|
15
|
+
* `session-log.md` 是追加型流水,天然比单条记忆大得多,沿用小上限 = 用户永远存不回去。
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_RAW_BYTES = 4 * 1024 * 1024
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「合法 .md 文件名」的**唯一**判据(零依赖,host 与 manifest 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:这条规则原先有 5 份实现(`memory/naming.js` 的 safeName、
|
|
5
|
+
* 设置页的 rawName、`setSettings` 里手写一遍、`config.js` 的 readExtraReserved 过滤、
|
|
6
|
+
* `typert.host.js` 的 fileNameSchema 正则)。它们**不是复制粘贴这么简单**:
|
|
7
|
+
* 有一份漏了 `i`、有一份少了 `:` 判定,于是同一份"能过的名字"在不同入口得到相反结论。
|
|
8
|
+
* 判据必须只有一份,平台相关的例外必须写成显式规则。
|
|
9
|
+
*
|
|
10
|
+
* 三条平台无关的底线(全部有事故来源):
|
|
11
|
+
* - `/` 与 `\\` 一律拒:Windows 当路径分隔符、Linux 不当,判据不能依赖 path.sep;
|
|
12
|
+
* - `:` 一律拒:NTFS 交替数据流(`MEMORY.md:evil.md` 能过扩展名判据却写到隐藏流);
|
|
13
|
+
* - 扩展名大小写不敏感:文件系统(NTFS/APFS)就是如此,`a.MD` 必须与 `a.md` 同宽。
|
|
14
|
+
*/
|
|
15
|
+
export const MD_NAME_RE = /^[^\\/:*?"<>|\u0000-\u001f]+\.md$/i
|
|
16
|
+
export const MD_NAME_PATTERN = MD_NAME_RE.source
|
|
17
|
+
|
|
18
|
+
/** 单段、`.md` 结尾、无路径分隔符 / 冒号 / 控制字符。**不含**保留名判定。 */
|
|
19
|
+
export function isPlainMdName(raw) {
|
|
20
|
+
return typeof raw === 'string' && raw !== '' && MD_NAME_RE.test(raw)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 索引与流水的规范名(小写)。它们不是"某条记忆",但多处要按名字识别。 */
|
|
24
|
+
export const INDEX_FILE_KEY = 'memory.md'
|
|
25
|
+
export const SESSION_LOG_KEY = 'session-log.md'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地时间戳的**唯一**实现(零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 原先三份:`memory/scan.js` 的 localStamp、`autodream/util.js` 的 stampHuman、
|
|
5
|
+
* `autodream/tools.js` 里那行与注释挤在一起的 localStamp。三份输出必须逐字相同 ——
|
|
6
|
+
* 它们都会写进**记忆文件本身**(archived_at)或报告,差一个补零规则就是数据不一致。
|
|
7
|
+
*
|
|
8
|
+
* 一律本地时间:这是给人看的("什么时候归档的"),不是机器间传输的 ISO 时间。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `YYYY-MM-DD` —— 只要日期的场合(提示词里的「今天是」)。 */
|
|
12
|
+
export function stampDate(ms = Date.now()) {
|
|
13
|
+
const d = new Date(ms)
|
|
14
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
15
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `YYYY-MM-DD HH:mm` —— 给人看(归档留痕、报告列表)。 */
|
|
19
|
+
export function stampHuman(ms = Date.now()) {
|
|
20
|
+
const d = new Date(ms)
|
|
21
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
22
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `YYYYMMDD-HHmmss` —— 给文件名用(快照目录 / 运行 id 的前半)。 */
|
|
26
|
+
export function stampCompact(ms = Date.now()) {
|
|
27
|
+
const d = new Date(ms)
|
|
28
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
29
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
30
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* schema 必须是 zod v4 实例(typert-loader 校验 "_zod" in schema)。
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod'
|
|
8
|
+
import { MD_NAME_PATTERN } from './shared/md-name.js'
|
|
9
|
+
import { MAX_FILE_BYTES, MAX_RAW_BYTES } from './shared/limits.js'
|
|
8
10
|
|
|
9
11
|
// 文件名:与 host 侧 safeName 同一套底线 —— 非空、无路径分隔符与 Windows 非法字符、
|
|
10
12
|
// 以 .md 结尾、长度有界。(真正的目录逃逸防护仍在 host 侧 safeName。)
|
|
@@ -13,10 +15,10 @@ import { z } from 'zod'
|
|
|
13
15
|
// 而文件名闸门原先**没有** `i`,两层判据宽度不一致 —— 宿主侧认可的 `a.MD`
|
|
14
16
|
// 会被传输入参 schema 挡在门外(读不了自己列表里列出来的文件)。大小写不敏感是
|
|
15
17
|
// NTFS / APFS 的事实,判据必须与文件系统同宽(与 `nameKey` 归一同一原则)。
|
|
16
|
-
const fileNameSchema = z.string().min(1).max(255).regex(
|
|
18
|
+
const fileNameSchema = z.string().min(1).max(255).regex(new RegExp(MD_NAME_PATTERN, 'i'))
|
|
17
19
|
// 正文:512 KB 上限。理由是「记忆要塞进 system prompt」——单条正常量级在几十 KB,
|
|
18
20
|
// 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
|
|
19
|
-
const fileContentSchema = z.string().max(
|
|
21
|
+
const fileContentSchema = z.string().max(MAX_FILE_BYTES)
|
|
20
22
|
|
|
21
23
|
// autodream 报告引用:比 fileNameSchema 宽——旧版报告列表里的条目带 `dream/` 前缀
|
|
22
24
|
// (新版 `autodream/` 不加前缀只给裸文件名)。这里放行斜杠,真正的目录逃逸防护
|
|
@@ -254,7 +256,7 @@ const rawWriteResultSchema = z.object({
|
|
|
254
256
|
}).readonly()
|
|
255
257
|
// 保留名的体积上限比单条记忆大得多(session-log.md 天然 > 512 KB)—— 4 MB,独立于
|
|
256
258
|
// fileContentSchema,别把两者混用。
|
|
257
|
-
const rawFileContentSchema = z.string().max(
|
|
259
|
+
const rawFileContentSchema = z.string().max(MAX_RAW_BYTES)
|
|
258
260
|
|
|
259
261
|
// ── autodream(夜间记忆整理)───────────────────────────────────────────────
|
|
260
262
|
// 配置的每一项都可写;lastResult 是一个「成功/失败两条分支共用」的结构,
|
package/package.json
CHANGED