@yottameta/yotta-memory 0.23.3 → 0.24.3
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/CHANGELOG.md +26 -0
- package/README.md +5 -2
- package/README.zh-CN.md +5 -2
- package/SKILL.md +18 -6
- package/USER_GUIDE.md +35 -3
- package/bin/yotta-memory.js +1179 -68
- package/package.json +1 -1
- package/references/faq.md +10 -1
- package/references/protocol.md +11 -7
- package/skill-manifest.json +1 -1
package/bin/yotta-memory.js
CHANGED
|
@@ -22,12 +22,13 @@ const os = require('os');
|
|
|
22
22
|
const crypto = require('crypto');
|
|
23
23
|
const http = require('http');
|
|
24
24
|
const net = require('net');
|
|
25
|
+
const zlib = require('zlib');
|
|
25
26
|
const child_process = require('child_process');
|
|
26
27
|
const { AsyncLocalStorage } = require('async_hooks');
|
|
27
28
|
|
|
28
|
-
const VERSION = '0.
|
|
29
|
-
const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path', '--from']);
|
|
30
|
-
const CLI_FLAG_OPTS = new Set(['--project', '--all', '--unsafe', '--no-auth', '--stdio', '--onstart', '--from-current', '--restart', '--force', '--attach', '--allow-same-volume', '--verify', '--no-hint', '--encrypt', '--no-encrypt', '--password-stdin', '--json', '--manual', '--skip-schedule', '--useful', '--useless', '--undo', '--dry-run', '--apply', '--purge', '--dedup', '--batches', '--propose', '--audit', '--capacity', '--rules', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--no-usage', '--yes', '--keep-memories', '--keep-identity']);
|
|
29
|
+
const VERSION = '0.24.3';
|
|
30
|
+
const CLI_VALUE_OPTS = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-id', '--agent-key', '--agent-key-file', '--plugin-data', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--recovery-key-out', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time', '--tools', '--mcp-config', '--skill-dir', '--year', '--evalset', '--k', '--seed', '--bootstrap', '--gate', '--against', '--template', '--path', '--from', '--from-plan', '--batch', '--profile']);
|
|
31
|
+
const CLI_FLAG_OPTS = new Set(['--project', '--all', '--unsafe', '--no-auth', '--stdio', '--onstart', '--from-current', '--restart', '--force', '--attach', '--allow-same-volume', '--verify', '--no-hint', '--encrypt', '--no-encrypt', '--password-stdin', '--json', '--manual', '--skip-schedule', '--useful', '--useless', '--undo', '--dry-run', '--apply', '--purge', '--dedup', '--batches', '--propose', '--audit', '--capacity', '--rules', '--explain', '--semantic', '--runtime', '--ablate', '--timing', '--baseline', '--probe', '--quarantine', '--restore', '--no-usage', '--yes', '--keep-memories', '--keep-identity', '--stats', '--include-flat', '--no-policy', '--policy-candidates']);
|
|
31
32
|
|
|
32
33
|
function helpOption(flag, arg, what, when, caution) {
|
|
33
34
|
return { flag: flag, arg: arg || '', what: what, when: when || '', caution: caution || '' };
|
|
@@ -119,6 +120,28 @@ const HELP_MODEL = [
|
|
|
119
120
|
helpOption('--template', '<文件>', '用模板声明显式期望(owner / 最小条数 / 类型条数 / 查询)', '有明确验收清单、要按清单核对时', '模板只加严判定,不会放宽或改写探针'),
|
|
120
121
|
helpOption('--json', '', '输出 JSON', '脚本或自动化读取体检结果时', ''),
|
|
121
122
|
] },
|
|
123
|
+
{ name: 'index', usage: 'index --stats [--json]', what: '只读分析公共索引体积、字段占比与 gzip 分片预估', when: '判断是否需要升级索引格式、压缩或分片时', options: [
|
|
124
|
+
helpOption('--stats', '', '输出索引统计报告', '分析索引膨胀根因与预估压缩收益时', '只读:不重建索引,不改任何记忆文件'),
|
|
125
|
+
helpOption('--json', '', '输出 JSON', '脚本读取字段体积、token 与压缩预估时', ''),
|
|
126
|
+
] },
|
|
127
|
+
{ name: 'layout', usage: 'layout <plan|apply|rollback> [选项]', what: '预演、执行或回滚日级目录重平衡', when: '单个年/月目录文件数超过阈值、需要可回滚迁移时', subcommands: [
|
|
128
|
+
helpSub('plan', 'plan [--threshold <数量>] [--include-flat] [--out <文件>] [--json]', '生成布局迁移计划', '先确定会移动哪些文件、目标路径和冲突时', [
|
|
129
|
+
helpOption('--threshold', '<数量>', '目录文件数超过该值时进入计划', '默认阈值不合适时', ''),
|
|
130
|
+
helpOption('--include-flat', '', '把旧平铺根也纳入计划', '确认愿意迁移旧平铺文件时', '默认只处理年/月桶,旧平铺保持原位'),
|
|
131
|
+
helpOption('--out', '<文件>', '把计划写到指定位置', '需要把计划交给维护窗口审阅时', ''),
|
|
132
|
+
helpOption('--json', '', '输出 JSON', '自动化读取候选、操作与冲突时', ''),
|
|
133
|
+
]),
|
|
134
|
+
helpSub('apply', 'apply --from-plan <文件> --yes [--json]', '执行已审阅的布局迁移计划', '维护窗口内确认计划无冲突后', [
|
|
135
|
+
helpOption('--from-plan', '<文件>', '指定布局迁移计划文件', '执行 plan 生成的计划时', ''),
|
|
136
|
+
helpOption('--yes', '', '跳过交互确认', '计划已审阅且维护窗口已开始时', '会移动记忆文件;失败时自动逆序回滚'),
|
|
137
|
+
helpOption('--json', '', '输出 JSON', '自动化执行迁移时', ''),
|
|
138
|
+
]),
|
|
139
|
+
helpSub('rollback', 'rollback --batch <id> --yes [--json]', '按 journal 回滚一次布局迁移', '迁移后发现问题、需要恢复原路径时', [
|
|
140
|
+
helpOption('--batch', '<id>', '指定布局迁移批次', '找回滚 journal 时', ''),
|
|
141
|
+
helpOption('--yes', '', '跳过交互确认', '已确认要恢复原布局时', '会移动记忆文件;按 journal 逆序恢复'),
|
|
142
|
+
helpOption('--json', '', '输出 JSON', '自动化回滚时', ''),
|
|
143
|
+
]),
|
|
144
|
+
] },
|
|
122
145
|
{ name: 'maintain', usage: 'maintain [选项]', what: '记忆自组织:归档、遗忘候选、去重和合并', when: '定期清理低价值记忆、合并重复条目时', options: [
|
|
123
146
|
helpOption('--capacity', '', '查看容量水位与淘汰 / 晋升建议', '库变大,想看哪些条目值得清理或提升时', '只读报告,不会自动归档或改权重'),
|
|
124
147
|
helpOption('--rules', '', '查看重复踩坑 → 建议升级为规则的清单', '同一类问题反复出现、想把它固化成 BOUND 规则时', '只读报告,不自动写规则;阈值走 config maintain_rule_min_hits'),
|
|
@@ -183,6 +206,9 @@ const HELP_MODEL = [
|
|
|
183
206
|
helpOption('--yes', '', '跳过交互确认', '自动化里执行隔离时', '跳过确认后不再二次询问,请先看一遍命中清单'),
|
|
184
207
|
helpOption('--restore', '', '还原最近一个隔离批次', '隔离后发现问题、要恢复原文时', '会覆盖当前文件;批次已还原过则拒绝重复还原'),
|
|
185
208
|
helpOption('--id', '<id>', '指定要还原的隔离批次', '要还原的不是最近一批时', ''),
|
|
209
|
+
helpOption('--no-policy', '', '忽略扫描例外策略做全量复核', '怀疑策略过期或想重新确认所有历史命中时', '会重新显示已抑制命中,不改策略文件'),
|
|
210
|
+
helpOption('--policy-candidates', '', '只生成例外策略候选清单', '历史安全语料需要人工复核后配置例外时', '只读扫描;不自动写入 security/scan-policy.json'),
|
|
211
|
+
helpOption('--out', '<文件>', '把候选清单写到文件', '想把候选交给人工复核或评审时', '仅用于 --policy-candidates;不会写策略文件'),
|
|
186
212
|
] },
|
|
187
213
|
{ name: 'reindex', usage: 'reindex', what: '重建记忆索引', when: '索引损坏、手动改过记忆文件或 doctor 提示索引异常时', options: [] },
|
|
188
214
|
{ name: 'export', usage: 'export --out <文件.json>', what: '导出全部记忆', when: '备份、迁移或做离线检查时', options: [helpOption('--out', '<文件>', '指定导出文件', '导出时必须给出目标 JSON 文件', '')] },
|
|
@@ -290,8 +316,9 @@ const HELP_MODEL = [
|
|
|
290
316
|
helpSub('status', 'status [--json]', '查看 current 与运行时状态', '排查版本漂移时', [helpOption('--json', '', '输出 JSON', '自动化读取运行时状态时', '')]),
|
|
291
317
|
helpSub('list', 'list [--json]', '列出已安装运行时版本', '盘点可切换版本时', [helpOption('--json', '', '输出 JSON', '自动化盘点运行时版本时', '')]),
|
|
292
318
|
] },
|
|
293
|
-
{ name: 'serve', usage: 'serve [--stdio] [--tools core|full] [--host <地址>] [--port <端口>]', what: '启动 MCP 记忆引擎', when: '宿主需要以 MCP 方式访问元忆时', options: [
|
|
319
|
+
{ name: 'serve', usage: 'serve [--stdio] [--profile <文件>] [--tools core|full] [--host <地址>] [--port <端口>]', what: '启动 MCP 记忆引擎', when: '宿主需要以 MCP 方式访问元忆时', options: [
|
|
294
320
|
helpOption('--stdio', '', '用本地 stdio 模式启动', '本地宿主不需要 HTTP 服务时', ''),
|
|
321
|
+
helpOption('--profile', '<文件>', '读取每用户服务 profile', '自启任务需要固定记忆库与运行时路径时', 'profile 不存在、路径无效或用户不匹配时拒绝启动'),
|
|
295
322
|
helpOption('--tools', 'core|full', '选择工具分组', '想减少工具常驻开销时', 'core 更省上下文,full 暴露全部工具'),
|
|
296
323
|
helpOption('--host', '<地址>', '设置 HTTP 监听地址', '需要远端 MCP 接入时', '监听公网前必须配置鉴权和防火墙'),
|
|
297
324
|
helpOption('--port', '<端口>', '设置 HTTP 监听端口', '默认端口被占用时', ''),
|
|
@@ -332,6 +359,11 @@ const VIEW_HELP_QUICK = [
|
|
|
332
359
|
{ cmd: 'backup restore', example: 'yotta-memory backup restore <id> --to <目录>', danger: true, note: '恢复会写入目标目录;覆盖非空目录需显式 --force,先确认目标和备份' },
|
|
333
360
|
{ cmd: 'backup drill', example: 'yotta-memory backup drill <id>', note: '先演练确认备份可用,再正式恢复' },
|
|
334
361
|
{ cmd: 'doctor', example: 'yotta-memory doctor' },
|
|
362
|
+
{ cmd: 'index', example: 'yotta-memory index --stats --json', note: '只读统计;--stats 不会重建索引' },
|
|
363
|
+
{ cmd: 'layout', example: 'yotta-memory layout <子命令>' },
|
|
364
|
+
{ cmd: 'layout plan', example: 'yotta-memory layout plan --json' },
|
|
365
|
+
{ cmd: 'layout apply', example: 'yotta-memory layout apply --from-plan <文件> --yes', danger: true, note: '会移动记忆文件;先审阅计划,失败时自动逆序回滚' },
|
|
366
|
+
{ cmd: 'layout rollback', example: 'yotta-memory layout rollback --batch <id> --yes', danger: true, note: '按 journal 逆序恢复原路径;先确认批次' },
|
|
335
367
|
{ cmd: 'maintain', example: 'yotta-memory maintain --capacity', note: '只读报告,不会自动归档或改权重' },
|
|
336
368
|
{ cmd: 'consolidate', example: 'yotta-memory consolidate --propose' },
|
|
337
369
|
{ cmd: 'distill', example: 'yotta-memory distill --model <命令>' },
|
|
@@ -1077,12 +1109,13 @@ function typeSubdir(type, owner) {
|
|
|
1077
1109
|
const id = owner || '';
|
|
1078
1110
|
return path.join(PRIVATE_DIR, id, TYPE_DIRS[t]);
|
|
1079
1111
|
}
|
|
1080
|
-
// v0.
|
|
1112
|
+
// v0.24.0 规模:新写入按 <yyyy>/<mm>/<dd> 分层(公共 facts/<yyyy>/<mm>/<dd>/,
|
|
1113
|
+
// 私密 private/<owner>/<type>/<yyyy>/<mm>/<dd>/)。
|
|
1081
1114
|
// 只影响新写入;旧平铺文件保持原位继续可读,不自动迁移。
|
|
1082
1115
|
function dateSubdir(dateStr) {
|
|
1083
1116
|
const d = String(dateStr || '');
|
|
1084
1117
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return '';
|
|
1085
|
-
return path.join(d.slice(0, 4), d.slice(5, 7));
|
|
1118
|
+
return path.join(d.slice(0, 4), d.slice(5, 7), d.slice(8, 10));
|
|
1086
1119
|
}
|
|
1087
1120
|
function entryWriteDir(root, type, owner, dateStr) {
|
|
1088
1121
|
const base = path.join(root, typeSubdir(type, owner));
|
|
@@ -1717,10 +1750,7 @@ function recentlyWrittenFiles(typeDir, writeDir) {
|
|
|
1717
1750
|
const yearDir = path.dirname(writeDir);
|
|
1718
1751
|
const seen = new Set(out);
|
|
1719
1752
|
if (fs.existsSync(yearDir)) {
|
|
1720
|
-
|
|
1721
|
-
const full = path.join(yearDir, fp);
|
|
1722
|
-
if (fs.statSync(full).isFile() && MEMORY_FILE_RE.test(fp) && !seen.has(full)) out.push(full);
|
|
1723
|
-
}
|
|
1753
|
+
walkEntryFiles(yearDir, out);
|
|
1724
1754
|
}
|
|
1725
1755
|
if (fs.existsSync(typeDir)) {
|
|
1726
1756
|
for (const f of fs.readdirSync(typeDir)) {
|
|
@@ -2287,14 +2317,38 @@ function writeProfileText(root, owner, text) {
|
|
|
2287
2317
|
|
|
2288
2318
|
// ---- 索引(index.json)----
|
|
2289
2319
|
function indexPath(root) { return path.join(root, INDEX_FILE); }
|
|
2290
|
-
const INDEX_VERSION =
|
|
2291
|
-
|
|
2320
|
+
const INDEX_VERSION = 5;
|
|
2321
|
+
const INDEX_MIN_SUPPORTED_VERSION = 4;
|
|
2322
|
+
// v0.24.0:索引按逻辑体积或条目数触发年份分片;大库使用 gzip 分片。
|
|
2292
2323
|
const INDEX_SHARD_THRESHOLD = 5000;
|
|
2293
|
-
|
|
2294
|
-
|
|
2324
|
+
const INDEX_COMPRESS_TRIGGER_BYTES = 4 * 1024 * 1024;
|
|
2325
|
+
// 安全校验:只接受 index-YYYY.json(.gz),防 manifest 篡改导致路径穿越。
|
|
2326
|
+
const SHARD_RE = /^index-\d{4}\.json(?:\.gz)?$/;
|
|
2295
2327
|
function isSafeShardName(name) { return typeof name === 'string' && SHARD_RE.test(name); }
|
|
2296
|
-
function
|
|
2297
|
-
function
|
|
2328
|
+
function isGzipShardName(name) { return typeof name === 'string' && /\.json\.gz$/.test(name); }
|
|
2329
|
+
function indexShardName(year, compressed) { return 'index-' + year + '.json' + (compressed === false ? '' : '.gz'); }
|
|
2330
|
+
function shardYear(name) {
|
|
2331
|
+
const match = String(name || '').match(/^index-(\d{4})\.json(?:\.gz)?$/);
|
|
2332
|
+
return match ? match[1] : '';
|
|
2333
|
+
}
|
|
2334
|
+
function readIndexShard(root, name) {
|
|
2335
|
+
if (!isSafeShardName(name)) return null;
|
|
2336
|
+
const full = path.join(root, name);
|
|
2337
|
+
if (!fs.existsSync(full)) return null;
|
|
2338
|
+
try {
|
|
2339
|
+
const raw = fs.readFileSync(full);
|
|
2340
|
+
const text = isGzipShardName(name) ? zlib.gunzipSync(raw).toString('utf8') : raw.toString('utf8');
|
|
2341
|
+
const parsed = JSON.parse(text);
|
|
2342
|
+
return parsed && Array.isArray(parsed.entries) ? parsed : null;
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
function writeIndexShard(root, name, payload) {
|
|
2348
|
+
const raw = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
2349
|
+
const data = isGzipShardName(name) ? zlib.gzipSync(raw, { level: 9, mtime: 0 }) : raw;
|
|
2350
|
+
atomicWriteFileSync(path.join(root, name), data);
|
|
2351
|
+
}
|
|
2298
2352
|
function entryYear(entry) {
|
|
2299
2353
|
return String((entry && entry.created) || '').slice(0, 4) || String(today()).slice(0, 4);
|
|
2300
2354
|
}
|
|
@@ -2317,7 +2371,7 @@ function loadIndexManifest(root) {
|
|
|
2317
2371
|
if (!fs.existsSync(p)) return null;
|
|
2318
2372
|
try {
|
|
2319
2373
|
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
2320
|
-
if (d && Array.isArray(d.shards)) return d;
|
|
2374
|
+
if (d && Array.isArray(d.shards) && (!d.version || d.version >= INDEX_MIN_SUPPORTED_VERSION)) return d;
|
|
2321
2375
|
} catch (e) { /* ignore */ }
|
|
2322
2376
|
return null;
|
|
2323
2377
|
}
|
|
@@ -2328,20 +2382,17 @@ function loadIndex(root) {
|
|
|
2328
2382
|
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
2329
2383
|
if (!d) return null;
|
|
2330
2384
|
if (Array.isArray(d.entries)) {
|
|
2331
|
-
//
|
|
2332
|
-
if (d.version && d.version >=
|
|
2385
|
+
// v4 平铺索引与 v5 平铺索引都继续读取;更旧版本返回 null 触发重建。
|
|
2386
|
+
if (d.version && d.version >= INDEX_MIN_SUPPORTED_VERSION) return d.entries;
|
|
2333
2387
|
return null;
|
|
2334
2388
|
}
|
|
2335
2389
|
if (Array.isArray(d.shards)) {
|
|
2336
|
-
|
|
2337
|
-
if (d.version && d.version < INDEX_VERSION) return null;
|
|
2390
|
+
if (d.version && d.version < INDEX_MIN_SUPPORTED_VERSION) return null;
|
|
2338
2391
|
const out = [];
|
|
2339
2392
|
for (const sh of d.shards) {
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
const sd = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
2344
|
-
if (sd && Array.isArray(sd.entries)) out.push.apply(out, sd.entries);
|
|
2393
|
+
const sd = readIndexShard(root, sh);
|
|
2394
|
+
if (!sd) return null;
|
|
2395
|
+
out.push.apply(out, sd.entries);
|
|
2345
2396
|
}
|
|
2346
2397
|
return out;
|
|
2347
2398
|
}
|
|
@@ -2361,21 +2412,20 @@ function loadIndexFor(root, options) {
|
|
|
2361
2412
|
const d = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
2362
2413
|
if (!d) return null;
|
|
2363
2414
|
if (Array.isArray(d.entries)) {
|
|
2364
|
-
if (d.version && d.version <
|
|
2415
|
+
if (d.version && d.version < INDEX_MIN_SUPPORTED_VERSION) return null;
|
|
2365
2416
|
const want = new Set(years);
|
|
2366
2417
|
return d.entries.filter(function (e) { return want.has(entryYear(e)); });
|
|
2367
2418
|
}
|
|
2368
2419
|
if (Array.isArray(d.shards)) {
|
|
2369
|
-
if (d.version && d.version <
|
|
2420
|
+
if (d.version && d.version < INDEX_MIN_SUPPORTED_VERSION) return null;
|
|
2370
2421
|
const want = new Set(years);
|
|
2371
2422
|
const out = [];
|
|
2372
2423
|
for (const sh of d.shards) {
|
|
2373
2424
|
if (!isSafeShardName(sh)) continue;
|
|
2374
2425
|
if (!want.has(shardYear(sh))) continue; // 只读取命中年份的分片文件
|
|
2375
|
-
const
|
|
2376
|
-
if (!
|
|
2377
|
-
|
|
2378
|
-
if (sd && Array.isArray(sd.entries)) out.push.apply(out, sd.entries);
|
|
2426
|
+
const sd = readIndexShard(root, sh);
|
|
2427
|
+
if (!sd) return null;
|
|
2428
|
+
out.push.apply(out, sd.entries);
|
|
2379
2429
|
}
|
|
2380
2430
|
return out;
|
|
2381
2431
|
}
|
|
@@ -2395,10 +2445,15 @@ function indexEntriesFor(root, options) {
|
|
|
2395
2445
|
return { entries: all.filter(function (e) { return want.has(entryYear(e)); }), error: '' };
|
|
2396
2446
|
}
|
|
2397
2447
|
function getIndex(root) { return loadIndex(root) || []; }
|
|
2398
|
-
function saveIndex(root, entries) {
|
|
2448
|
+
function saveIndex(root, entries, options) {
|
|
2449
|
+
options = options || {};
|
|
2399
2450
|
const clean = entries.map(function (e) { const c = Object.assign({}, e); delete c.meta; return c; });
|
|
2400
2451
|
const old = loadIndexManifest(root);
|
|
2401
|
-
|
|
2452
|
+
const shardThreshold = options.shardThresholdEntries === undefined ? INDEX_SHARD_THRESHOLD : Number(options.shardThresholdEntries);
|
|
2453
|
+
const byteThreshold = options.compressThresholdBytes === undefined ? INDEX_COMPRESS_TRIGGER_BYTES : Number(options.compressThresholdBytes);
|
|
2454
|
+
const compress = options.compress !== false;
|
|
2455
|
+
const logicalBytes = jsonByteLength(clean);
|
|
2456
|
+
if (clean.length > shardThreshold || logicalBytes > byteThreshold) {
|
|
2402
2457
|
const byYear = {};
|
|
2403
2458
|
for (const e of clean) {
|
|
2404
2459
|
const y = entryYear(e);
|
|
@@ -2406,21 +2461,206 @@ function saveIndex(root, entries) {
|
|
|
2406
2461
|
}
|
|
2407
2462
|
const newShards = [];
|
|
2408
2463
|
for (const y of Object.keys(byYear).sort()) {
|
|
2409
|
-
const name = indexShardName(y);
|
|
2410
|
-
|
|
2464
|
+
const name = indexShardName(y, compress);
|
|
2465
|
+
writeIndexShard(root, name, { version: INDEX_VERSION, year: (parseInt(y, 10) || 0), entries: byYear[y] });
|
|
2411
2466
|
newShards.push(name);
|
|
2412
2467
|
}
|
|
2413
|
-
atomicWriteFileSync(indexPath(root), JSON.stringify({
|
|
2468
|
+
atomicWriteFileSync(indexPath(root), JSON.stringify({
|
|
2469
|
+
version: INDEX_VERSION,
|
|
2470
|
+
updated: today(),
|
|
2471
|
+
format: compress ? 'gzip-year' : 'json-year',
|
|
2472
|
+
shards: newShards,
|
|
2473
|
+
count: clean.length,
|
|
2474
|
+
}, null, 2), 'utf8');
|
|
2414
2475
|
if (old && Array.isArray(old.shards)) {
|
|
2415
2476
|
for (const sh of old.shards) if (newShards.indexOf(sh) === -1 && isSafeShardName(sh)) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
|
|
2416
2477
|
}
|
|
2417
2478
|
} else {
|
|
2479
|
+
atomicWriteFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), entries: clean }, null, 2), 'utf8');
|
|
2418
2480
|
if (old && Array.isArray(old.shards)) {
|
|
2419
2481
|
for (const sh of old.shards) { try { fs.unlinkSync(path.join(root, sh)); } catch (e) {} }
|
|
2420
2482
|
}
|
|
2421
|
-
atomicWriteFileSync(indexPath(root), JSON.stringify({ version: INDEX_VERSION, updated: today(), entries: clean }, null, 2), 'utf8');
|
|
2422
2483
|
}
|
|
2423
2484
|
}
|
|
2485
|
+
function jsonByteLength(value) {
|
|
2486
|
+
return Buffer.byteLength(JSON.stringify(value === undefined ? null : value), 'utf8');
|
|
2487
|
+
}
|
|
2488
|
+
function indexFieldBytes(entries) {
|
|
2489
|
+
const groups = {
|
|
2490
|
+
tokens: ['tokens'],
|
|
2491
|
+
statement: ['statement'],
|
|
2492
|
+
subject: ['subject'],
|
|
2493
|
+
tags: ['tags'],
|
|
2494
|
+
usage: ['access_count', 'last_accessed', 'feedback_net', 'hit_days', 'hit_queries'],
|
|
2495
|
+
};
|
|
2496
|
+
const grouped = new Set();
|
|
2497
|
+
for (const names of Object.values(groups)) for (const name of names) grouped.add(name);
|
|
2498
|
+
const out = { tokens: 0, statement: 0, subject: 0, tags: 0, usage: 0, other: 0, total: 0, overhead: 0 };
|
|
2499
|
+
for (const entry of entries) {
|
|
2500
|
+
out.total += jsonByteLength(entry);
|
|
2501
|
+
for (const group of Object.keys(groups)) {
|
|
2502
|
+
for (const name of groups[group]) out[group] += jsonByteLength(entry[name]);
|
|
2503
|
+
}
|
|
2504
|
+
for (const name of Object.keys(entry)) {
|
|
2505
|
+
if (!grouped.has(name)) out.other += jsonByteLength(entry[name]);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
out.overhead = Math.max(0, out.total - (out.tokens + out.statement + out.subject + out.tags + out.usage + out.other));
|
|
2509
|
+
return out;
|
|
2510
|
+
}
|
|
2511
|
+
function indexTokenStats(entries) {
|
|
2512
|
+
let unique = 0;
|
|
2513
|
+
let weighted = 0;
|
|
2514
|
+
let pinyin = 0;
|
|
2515
|
+
for (const entry of entries) {
|
|
2516
|
+
const tokens = entry && entry.tokens && typeof entry.tokens === 'object' ? entry.tokens : {};
|
|
2517
|
+
for (const key of Object.keys(tokens)) {
|
|
2518
|
+
unique += 1;
|
|
2519
|
+
weighted += Number(tokens[key]) || 0;
|
|
2520
|
+
if (key.indexOf('py:') === 0 || key.indexOf('pyi:') === 0) pinyin += 1;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return {
|
|
2524
|
+
unique: unique,
|
|
2525
|
+
weighted: Math.round(weighted * 1000) / 1000,
|
|
2526
|
+
pinyin: pinyin,
|
|
2527
|
+
averageUniquePerEntry: entries.length ? Math.round((unique / entries.length) * 1000) / 1000 : 0,
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
function indexGzipProjection(entries) {
|
|
2531
|
+
const byYear = {};
|
|
2532
|
+
for (const entry of entries) {
|
|
2533
|
+
const clean = Object.assign({}, entry);
|
|
2534
|
+
delete clean.meta;
|
|
2535
|
+
const year = entryYear(clean);
|
|
2536
|
+
(byYear[year] = byYear[year] || []).push(clean);
|
|
2537
|
+
}
|
|
2538
|
+
const shards = [];
|
|
2539
|
+
let gzipBytes = 0;
|
|
2540
|
+
for (const year of Object.keys(byYear).sort()) {
|
|
2541
|
+
const raw = Buffer.from(JSON.stringify({ version: 5, year: parseInt(year, 10) || 0, entries: byYear[year] }), 'utf8');
|
|
2542
|
+
const compressed = zlib.gzipSync(raw, { level: 9, mtime: 0 });
|
|
2543
|
+
gzipBytes += compressed.length;
|
|
2544
|
+
shards.push({
|
|
2545
|
+
year: year,
|
|
2546
|
+
entries: byYear[year].length,
|
|
2547
|
+
jsonBytes: raw.length,
|
|
2548
|
+
gzipBytes: compressed.length,
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
const manifest = Buffer.from(JSON.stringify({
|
|
2552
|
+
version: 5,
|
|
2553
|
+
updated: '',
|
|
2554
|
+
shards: shards.map(function (item) { return 'index-' + item.year + '.json.gz'; }),
|
|
2555
|
+
count: entries.length,
|
|
2556
|
+
}), 'utf8');
|
|
2557
|
+
const storageBytes = gzipBytes + manifest.length;
|
|
2558
|
+
return {
|
|
2559
|
+
format: 'index-v5-gzip-year',
|
|
2560
|
+
storageBytes: storageBytes,
|
|
2561
|
+
manifestBytes: manifest.length,
|
|
2562
|
+
shards: shards,
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
function indexStatsCore(opts) {
|
|
2566
|
+
opts = opts || {};
|
|
2567
|
+
const root = path.resolve(opts.root || userRoot());
|
|
2568
|
+
const idx = indexPath(root);
|
|
2569
|
+
if (!fs.existsSync(idx)) {
|
|
2570
|
+
return { error: true, format: 'missing', entryCount: 0, storageBytes: 0, text: '索引不存在: ' + idx + '。请先运行 yotta-memory reindex。' };
|
|
2571
|
+
}
|
|
2572
|
+
let manifestBytes = 0;
|
|
2573
|
+
let data = null;
|
|
2574
|
+
try {
|
|
2575
|
+
manifestBytes = fs.statSync(idx).size;
|
|
2576
|
+
data = JSON.parse(fs.readFileSync(idx, 'utf8'));
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
return { error: true, format: 'invalid', entryCount: 0, storageBytes: manifestBytes, text: '索引无法解析: ' + error.message };
|
|
2579
|
+
}
|
|
2580
|
+
if (!data || (!Array.isArray(data.entries) && !Array.isArray(data.shards))) {
|
|
2581
|
+
return { error: true, format: 'invalid', entryCount: 0, storageBytes: manifestBytes, text: '索引结构无效:缺少 entries 或 shards 数组。' };
|
|
2582
|
+
}
|
|
2583
|
+
const warnings = [];
|
|
2584
|
+
const entries = [];
|
|
2585
|
+
let shardBytes = 0;
|
|
2586
|
+
const shards = [];
|
|
2587
|
+
if (Array.isArray(data.entries)) {
|
|
2588
|
+
entries.push.apply(entries, data.entries);
|
|
2589
|
+
} else {
|
|
2590
|
+
for (const shard of data.shards) {
|
|
2591
|
+
if (!isSafeShardName(shard)) {
|
|
2592
|
+
warnings.push('忽略不安全分片名: ' + String(shard));
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
const full = path.join(root, shard);
|
|
2596
|
+
if (!fs.existsSync(full)) {
|
|
2597
|
+
warnings.push('分片缺失: ' + shard);
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
try {
|
|
2601
|
+
const bytes = fs.statSync(full).size;
|
|
2602
|
+
const parsed = readIndexShard(root, shard);
|
|
2603
|
+
shardBytes += bytes;
|
|
2604
|
+
if (parsed) {
|
|
2605
|
+
entries.push.apply(entries, parsed.entries);
|
|
2606
|
+
shards.push({ name: shard, bytes: bytes, entries: parsed.entries.length });
|
|
2607
|
+
} else {
|
|
2608
|
+
warnings.push('分片结构无效: ' + shard);
|
|
2609
|
+
}
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
warnings.push('分片无法解析: ' + shard + '(' + error.message + ')');
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
const storageBytes = manifestBytes + shardBytes;
|
|
2616
|
+
const logicalBytes = jsonByteLength(entries);
|
|
2617
|
+
const fieldBytes = indexFieldBytes(entries);
|
|
2618
|
+
const tokens = indexTokenStats(entries);
|
|
2619
|
+
const gzipProjection = indexGzipProjection(entries);
|
|
2620
|
+
const warnBytes = scaleThreshold(loadConfig(), 'scale_warn_index_bytes');
|
|
2621
|
+
gzipProjection.underWarningThreshold = gzipProjection.storageBytes <= warnBytes;
|
|
2622
|
+
gzipProjection.warningThresholdBytes = warnBytes;
|
|
2623
|
+
gzipProjection.compressionRatio = storageBytes ? Math.round((gzipProjection.storageBytes / storageBytes) * 1000) / 1000 : 0;
|
|
2624
|
+
const triggerReasons = [];
|
|
2625
|
+
if (entries.length > INDEX_SHARD_THRESHOLD) triggerReasons.push('entries');
|
|
2626
|
+
if (logicalBytes > INDEX_COMPRESS_TRIGGER_BYTES) triggerReasons.push('logical_bytes');
|
|
2627
|
+
const lines = [
|
|
2628
|
+
'索引统计(只读)',
|
|
2629
|
+
'- 格式: ' + (Array.isArray(data.entries) ? 'inline' : 'sharded'),
|
|
2630
|
+
'- 条目: ' + entries.length,
|
|
2631
|
+
'- 索引存储: ' + storageBytes + ' 字节(manifest ' + manifestBytes + ' / 分片 ' + shardBytes + ')',
|
|
2632
|
+
'- 逻辑原始体积: ' + logicalBytes + ' 字节',
|
|
2633
|
+
'- token: ' + tokens.unique + ' 个唯一 token(加权 ' + tokens.weighted + ' / 拼音 ' + tokens.pinyin + ' / 平均 ' + tokens.averageUniquePerEntry + ' 每条例)',
|
|
2634
|
+
'- 字段体积: tokens ' + fieldBytes.tokens + ' / statement ' + fieldBytes.statement + ' / subject ' + fieldBytes.subject + ' / tags ' + fieldBytes.tags + ' / usage ' + fieldBytes.usage + ' / other ' + fieldBytes.other,
|
|
2635
|
+
'- gzip 年份分片预估: ' + gzipProjection.storageBytes + ' 字节(压缩比 ' + gzipProjection.compressionRatio + ' / 低于告警线 ' + (gzipProjection.underWarningThreshold ? '是' : '否') + ')',
|
|
2636
|
+
'- 触发建议: ' + (triggerReasons.length ? triggerReasons.join(' + ') : '当前无需按现有阈值分片'),
|
|
2637
|
+
];
|
|
2638
|
+
if (warnings.length) lines.push('- 告警: ' + warnings.join(';'));
|
|
2639
|
+
return {
|
|
2640
|
+
error: false,
|
|
2641
|
+
root: root,
|
|
2642
|
+
format: Array.isArray(data.entries) ? 'inline' : 'sharded',
|
|
2643
|
+
version: Number(data.version || 0),
|
|
2644
|
+
updated: data.updated || '',
|
|
2645
|
+
entryCount: entries.length,
|
|
2646
|
+
shardCount: shards.length,
|
|
2647
|
+
shards: shards,
|
|
2648
|
+
manifestBytes: manifestBytes,
|
|
2649
|
+
shardBytes: shardBytes,
|
|
2650
|
+
storageBytes: storageBytes,
|
|
2651
|
+
logicalBytes: logicalBytes,
|
|
2652
|
+
fieldBytes: fieldBytes,
|
|
2653
|
+
tokens: tokens,
|
|
2654
|
+
gzipProjection: gzipProjection,
|
|
2655
|
+
trigger: {
|
|
2656
|
+
entryThreshold: INDEX_SHARD_THRESHOLD,
|
|
2657
|
+
logicalByteThreshold: INDEX_COMPRESS_TRIGGER_BYTES,
|
|
2658
|
+
reasons: triggerReasons,
|
|
2659
|
+
},
|
|
2660
|
+
warnings: warnings,
|
|
2661
|
+
text: lines.join('\n'),
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2424
2664
|
function collectEntryFiles(root) {
|
|
2425
2665
|
const dirs = [];
|
|
2426
2666
|
const facts = path.join(root, PUBLIC_DIR);
|
|
@@ -2534,6 +2774,318 @@ function layoutCollisionReport(root) {
|
|
|
2534
2774
|
report.ok = report.conflicts === 0 && report.unverified === 0;
|
|
2535
2775
|
return report;
|
|
2536
2776
|
}
|
|
2777
|
+
const LAYOUT_PLAN_DIR = path.join('.maintenance', 'layout-plans');
|
|
2778
|
+
const LAYOUT_JOURNAL_DIR = path.join('.maintenance', 'layout-journals');
|
|
2779
|
+
function safeLayoutRel(value) {
|
|
2780
|
+
const rel = String(value || '').replace(/\\/g, '/');
|
|
2781
|
+
if (!rel || path.isAbsolute(rel) || rel.split('/').includes('..')) return false;
|
|
2782
|
+
if (!MEMORY_FILE_RE.test(path.posix.basename(rel))) return false;
|
|
2783
|
+
return rel.indexOf(PUBLIC_DIR + '/') === 0 || rel.indexOf(PRIVATE_DIR + '/') === 0;
|
|
2784
|
+
}
|
|
2785
|
+
function layoutInsideRoot(root, rel) {
|
|
2786
|
+
const base = path.resolve(root);
|
|
2787
|
+
const full = path.resolve(root, String(rel || ''));
|
|
2788
|
+
return full === base || full.indexOf(base + path.sep) === 0;
|
|
2789
|
+
}
|
|
2790
|
+
function layoutBaseInfo(rel) {
|
|
2791
|
+
const parts = String(rel || '').split('/');
|
|
2792
|
+
if (parts[0] === PUBLIC_DIR) return { kind: 'public', baseDepth: 1, owner: '', type: 'facts' };
|
|
2793
|
+
if (parts[0] === PRIVATE_DIR && parts.length >= 3) {
|
|
2794
|
+
return { kind: 'private', baseDepth: 3, owner: parts[1], type: parts[2] };
|
|
2795
|
+
}
|
|
2796
|
+
return null;
|
|
2797
|
+
}
|
|
2798
|
+
function isLayoutMonthBucket(rel, includeFlat) {
|
|
2799
|
+
const parts = String(rel || '').split('/');
|
|
2800
|
+
const info = layoutBaseInfo(rel);
|
|
2801
|
+
if (!info) return false;
|
|
2802
|
+
if (parts.length === info.baseDepth) return !!includeFlat;
|
|
2803
|
+
if (parts.length !== info.baseDepth + 2) return false;
|
|
2804
|
+
return /^\d{4}$/.test(parts[info.baseDepth]) && /^\d{2}$/.test(parts[info.baseDepth + 1]);
|
|
2805
|
+
}
|
|
2806
|
+
function layoutTargetFor(rel) {
|
|
2807
|
+
const parts = String(rel || '').split('/');
|
|
2808
|
+
const info = layoutBaseInfo(rel);
|
|
2809
|
+
const base = path.basename(rel);
|
|
2810
|
+
const match = base.match(/^(\d{4})-(\d{2})-(\d{2})-\d{4}\.md(?:\.enc)?$/);
|
|
2811
|
+
if (!info || !match) return '';
|
|
2812
|
+
return parts.slice(0, info.baseDepth).concat([match[1], match[2], match[3], base]).join('/');
|
|
2813
|
+
}
|
|
2814
|
+
function layoutCandidateDirs(root, includeFlat, threshold) {
|
|
2815
|
+
const seeds = [];
|
|
2816
|
+
const facts = path.join(root, PUBLIC_DIR);
|
|
2817
|
+
if (fs.existsSync(facts)) seeds.push({ dir: facts, baseDepth: 1, owner: '' });
|
|
2818
|
+
const pdir = path.join(root, PRIVATE_DIR);
|
|
2819
|
+
if (fs.existsSync(pdir)) {
|
|
2820
|
+
for (const owner of fs.readdirSync(pdir)) {
|
|
2821
|
+
const ownerDir = path.join(pdir, owner);
|
|
2822
|
+
if (!fs.statSync(ownerDir).isDirectory()) continue;
|
|
2823
|
+
for (const type of PRIVATE_LEAF) {
|
|
2824
|
+
const typeDir = path.join(ownerDir, type);
|
|
2825
|
+
if (fs.existsSync(typeDir)) seeds.push({ dir: typeDir, baseDepth: 3, owner: owner });
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const found = [];
|
|
2830
|
+
const visit = function (dir) {
|
|
2831
|
+
const rel = path.relative(root, dir).replace(/\\/g, '/');
|
|
2832
|
+
if (isLayoutMonthBucket(rel, includeFlat)) {
|
|
2833
|
+
const files = fs.readdirSync(dir, { withFileTypes: true })
|
|
2834
|
+
.filter(function (entry) { return entry.isFile() && MEMORY_FILE_RE.test(entry.name); })
|
|
2835
|
+
.map(function (entry) { return entry.name; });
|
|
2836
|
+
if (files.length > threshold) {
|
|
2837
|
+
const info = layoutBaseInfo(rel);
|
|
2838
|
+
if (info && info.kind === 'private' && isEncrypted(root) && !getOwnerKeyFor(root, info.owner)) {
|
|
2839
|
+
found.push({ path: rel, files: files.length, skipped: true, reason: 'encrypted_owner_without_key' });
|
|
2840
|
+
} else {
|
|
2841
|
+
found.push({ path: rel, files: files.length, skipped: false, reason: '' });
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2846
|
+
if (entry.isDirectory()) visit(path.join(dir, entry.name));
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
for (const seed of seeds) visit(seed.dir);
|
|
2850
|
+
return found.sort(function (a, b) { return a.path.localeCompare(b.path); });
|
|
2851
|
+
}
|
|
2852
|
+
function layoutBatchId(now) {
|
|
2853
|
+
return 'layout-' + now.toISOString().replace(/[:.]/g, '-') + '-' + crypto.randomBytes(3).toString('hex');
|
|
2854
|
+
}
|
|
2855
|
+
function layoutPlanCore(opts) {
|
|
2856
|
+
opts = opts || {};
|
|
2857
|
+
const root = path.resolve(opts.root || userRoot());
|
|
2858
|
+
const cfg = loadConfig();
|
|
2859
|
+
const threshold = opts.threshold === undefined ? scaleThreshold(cfg, 'scale_warn_files_per_dir') : Number(opts.threshold);
|
|
2860
|
+
const includeFlat = !!opts.includeFlat;
|
|
2861
|
+
const now = opts.now ? new Date(opts.now) : new Date();
|
|
2862
|
+
const batch = layoutBatchId(now);
|
|
2863
|
+
const candidates = layoutCandidateDirs(root, includeFlat, threshold);
|
|
2864
|
+
const operations = [];
|
|
2865
|
+
const conflicts = [];
|
|
2866
|
+
const warnings = [];
|
|
2867
|
+
for (const candidate of candidates) {
|
|
2868
|
+
if (candidate.skipped) {
|
|
2869
|
+
warnings.push('跳过 ' + candidate.path + ': 加密 owner 无可用密钥');
|
|
2870
|
+
continue;
|
|
2871
|
+
}
|
|
2872
|
+
const dir = path.join(root, candidate.path);
|
|
2873
|
+
for (const name of fs.readdirSync(dir)) {
|
|
2874
|
+
if (!MEMORY_FILE_RE.test(name)) continue;
|
|
2875
|
+
const from = path.posix.join(candidate.path, name);
|
|
2876
|
+
const to = layoutTargetFor(from);
|
|
2877
|
+
if (!to || !safeLayoutRel(to)) {
|
|
2878
|
+
conflicts.push({ from: from, to: to, reason: 'target_invalid' });
|
|
2879
|
+
continue;
|
|
2880
|
+
}
|
|
2881
|
+
if (fs.existsSync(path.join(root, to))) {
|
|
2882
|
+
conflicts.push({ from: from, to: to, reason: 'target_exists' });
|
|
2883
|
+
continue;
|
|
2884
|
+
}
|
|
2885
|
+
operations.push({
|
|
2886
|
+
from: from,
|
|
2887
|
+
to: to,
|
|
2888
|
+
sha256: normalizedLayoutDigest(fs.readFileSync(path.join(root, from))),
|
|
2889
|
+
status: 'pending',
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
operations.sort(function (a, b) { return a.from.localeCompare(b.from); });
|
|
2894
|
+
const plan = {
|
|
2895
|
+
batch: batch,
|
|
2896
|
+
createdAt: now.toISOString(),
|
|
2897
|
+
root: root,
|
|
2898
|
+
threshold: threshold,
|
|
2899
|
+
includeFlat: includeFlat,
|
|
2900
|
+
candidates: candidates,
|
|
2901
|
+
operations: operations,
|
|
2902
|
+
conflicts: conflicts,
|
|
2903
|
+
warnings: warnings,
|
|
2904
|
+
planFile: '',
|
|
2905
|
+
};
|
|
2906
|
+
if (conflicts.length) {
|
|
2907
|
+
plan.error = true;
|
|
2908
|
+
plan.text = '布局迁移计划存在 ' + conflicts.length + ' 个冲突,拒绝生成可执行计划。\n'
|
|
2909
|
+
+ conflicts.map(function (item) { return '- ' + item.from + ' -> ' + item.to + '(' + item.reason + ')'; }).join('\n');
|
|
2910
|
+
return plan;
|
|
2911
|
+
}
|
|
2912
|
+
const planFile = opts.out
|
|
2913
|
+
? path.resolve(String(opts.out))
|
|
2914
|
+
: path.join(root, LAYOUT_PLAN_DIR, batch + '.json');
|
|
2915
|
+
fs.mkdirSync(path.dirname(planFile), { recursive: true });
|
|
2916
|
+
plan.planFile = planFile;
|
|
2917
|
+
atomicWriteFileSync(planFile, JSON.stringify(plan, null, 2), 'utf8');
|
|
2918
|
+
plan.error = false;
|
|
2919
|
+
plan.text = '布局迁移计划: ' + batch + '\n- 目录: ' + candidates.length + ' 个候选'
|
|
2920
|
+
+ '(可迁移 ' + candidates.filter(function (item) { return !item.skipped; }).length + ')\n'
|
|
2921
|
+
+ '- 文件: ' + operations.length + ' 个\n- 计划: ' + planFile
|
|
2922
|
+
+ (warnings.length ? '\n- 告警: ' + warnings.join(';') : '');
|
|
2923
|
+
return plan;
|
|
2924
|
+
}
|
|
2925
|
+
function validateLayoutPlan(root, plan) {
|
|
2926
|
+
if (!plan || plan.root !== root) return '计划 root 与当前记忆库不一致,拒绝执行。';
|
|
2927
|
+
if (!/^layout-[A-Za-z0-9-]+$/.test(String(plan.batch || ''))) return '计划 batch 不安全。';
|
|
2928
|
+
if (!Array.isArray(plan.operations) || !plan.operations.length) return '计划没有可执行文件。';
|
|
2929
|
+
if (Array.isArray(plan.conflicts) && plan.conflicts.length) return '计划包含冲突,拒绝执行。';
|
|
2930
|
+
for (const op of plan.operations) {
|
|
2931
|
+
if (!safeLayoutRel(op.from) || !safeLayoutRel(op.to) || !layoutInsideRoot(root, op.from) || !layoutInsideRoot(root, op.to)) {
|
|
2932
|
+
return '计划包含不安全路径: ' + String(op.from) + ' -> ' + String(op.to);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return '';
|
|
2936
|
+
}
|
|
2937
|
+
function writeLayoutJournal(pathname, journal) {
|
|
2938
|
+
atomicWriteFileSync(pathname, JSON.stringify(journal, null, 2), 'utf8');
|
|
2939
|
+
}
|
|
2940
|
+
function restoreLayoutDone(root, journal, pathname) {
|
|
2941
|
+
let ok = true;
|
|
2942
|
+
for (let i = journal.operations.length - 1; i >= 0; i--) {
|
|
2943
|
+
const op = journal.operations[i];
|
|
2944
|
+
if (op.status !== 'done') continue;
|
|
2945
|
+
const from = path.join(root, op.from);
|
|
2946
|
+
const to = path.join(root, op.to);
|
|
2947
|
+
if (!fs.existsSync(to) || fs.existsSync(from)) {
|
|
2948
|
+
op.status = 'rollback_conflict';
|
|
2949
|
+
ok = false;
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
fs.mkdirSync(path.dirname(from), { recursive: true });
|
|
2953
|
+
fs.renameSync(to, from);
|
|
2954
|
+
op.status = 'rolled_back';
|
|
2955
|
+
writeLayoutJournal(pathname, journal);
|
|
2956
|
+
}
|
|
2957
|
+
return ok;
|
|
2958
|
+
}
|
|
2959
|
+
function withLayoutLock(root, fn) {
|
|
2960
|
+
const lock = path.join(root, '.maintenance', 'layout.lock');
|
|
2961
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
2962
|
+
try {
|
|
2963
|
+
fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, created: new Date().toISOString() }), { encoding: 'utf8', flag: 'wx' });
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
return { error: true, text: '已有布局迁移在运行或上次未清理 lock: ' + lock };
|
|
2966
|
+
}
|
|
2967
|
+
try {
|
|
2968
|
+
return fn();
|
|
2969
|
+
} finally {
|
|
2970
|
+
try { fs.unlinkSync(lock); } catch (error) { /* ignore */ }
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
function layoutApplyCore(opts) {
|
|
2974
|
+
opts = opts || {};
|
|
2975
|
+
const root = path.resolve(opts.root || userRoot());
|
|
2976
|
+
if (!opts.yes) return { error: true, text: '拒绝执行布局迁移:请复核计划后显式加 --yes。' };
|
|
2977
|
+
if (!opts.planFile) return { error: true, text: '请用 --from-plan <文件> 指定布局迁移计划。' };
|
|
2978
|
+
const planFile = path.resolve(String(opts.planFile));
|
|
2979
|
+
let plan = null;
|
|
2980
|
+
try { plan = JSON.parse(fs.readFileSync(planFile, 'utf8')); } catch (error) {
|
|
2981
|
+
return { error: true, text: '无法读取布局迁移计划: ' + error.message };
|
|
2982
|
+
}
|
|
2983
|
+
const invalid = validateLayoutPlan(root, plan);
|
|
2984
|
+
if (invalid) return { error: true, text: invalid };
|
|
2985
|
+
return withLayoutLock(root, function () {
|
|
2986
|
+
const journalFile = path.join(root, LAYOUT_JOURNAL_DIR, plan.batch + '.json');
|
|
2987
|
+
if (fs.existsSync(journalFile)) return { error: true, text: '该计划已有 journal,拒绝重复执行: ' + journalFile };
|
|
2988
|
+
const journal = {
|
|
2989
|
+
batch: plan.batch,
|
|
2990
|
+
createdAt: plan.createdAt,
|
|
2991
|
+
startedAt: new Date().toISOString(),
|
|
2992
|
+
root: root,
|
|
2993
|
+
status: 'applying',
|
|
2994
|
+
operations: plan.operations.map(function (op) {
|
|
2995
|
+
return { from: op.from, to: op.to, sha256: op.sha256, status: 'pending' };
|
|
2996
|
+
}),
|
|
2997
|
+
};
|
|
2998
|
+
fs.mkdirSync(path.dirname(journalFile), { recursive: true });
|
|
2999
|
+
writeLayoutJournal(journalFile, journal);
|
|
3000
|
+
let moved = 0;
|
|
3001
|
+
try {
|
|
3002
|
+
for (const op of journal.operations) {
|
|
3003
|
+
const from = path.join(root, op.from);
|
|
3004
|
+
const to = path.join(root, op.to);
|
|
3005
|
+
if (!fs.existsSync(from)) throw new Error('源文件缺失: ' + op.from);
|
|
3006
|
+
const digest = normalizedLayoutDigest(fs.readFileSync(from));
|
|
3007
|
+
if (digest !== op.sha256) throw new Error('源文件哈希变化: ' + op.from);
|
|
3008
|
+
if (fs.existsSync(to)) throw new Error('目标已存在: ' + op.to);
|
|
3009
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
3010
|
+
fs.renameSync(from, to);
|
|
3011
|
+
op.status = 'done';
|
|
3012
|
+
moved += 1;
|
|
3013
|
+
writeLayoutJournal(journalFile, journal);
|
|
3014
|
+
}
|
|
3015
|
+
buildIndex(root);
|
|
3016
|
+
journal.status = 'applied';
|
|
3017
|
+
journal.completedAt = new Date().toISOString();
|
|
3018
|
+
writeLayoutJournal(journalFile, journal);
|
|
3019
|
+
return {
|
|
3020
|
+
error: false,
|
|
3021
|
+
batch: plan.batch,
|
|
3022
|
+
moved: moved,
|
|
3023
|
+
rolledBack: false,
|
|
3024
|
+
planFile: planFile,
|
|
3025
|
+
journalFile: journalFile,
|
|
3026
|
+
text: '布局迁移完成: ' + plan.batch + '(' + moved + ' 文件)\n计划: ' + planFile + '\nJournal: ' + journalFile,
|
|
3027
|
+
};
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
const rolledBack = restoreLayoutDone(root, journal, journalFile);
|
|
3030
|
+
journal.status = rolledBack ? 'rolled_back' : 'rollback_failed';
|
|
3031
|
+
journal.error = error.message;
|
|
3032
|
+
journal.completedAt = new Date().toISOString();
|
|
3033
|
+
writeLayoutJournal(journalFile, journal);
|
|
3034
|
+
try { buildIndex(root); } catch (e) { /* ignore */ }
|
|
3035
|
+
return {
|
|
3036
|
+
error: true,
|
|
3037
|
+
batch: plan.batch,
|
|
3038
|
+
moved: moved,
|
|
3039
|
+
rolledBack: rolledBack,
|
|
3040
|
+
planFile: planFile,
|
|
3041
|
+
journalFile: journalFile,
|
|
3042
|
+
text: '布局迁移失败并' + (rolledBack ? '已回滚' : '回滚不完整') + ': ' + error.message + '\nJournal: ' + journalFile,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
function layoutRollbackCore(opts) {
|
|
3048
|
+
opts = opts || {};
|
|
3049
|
+
const root = path.resolve(opts.root || userRoot());
|
|
3050
|
+
if (!opts.yes) return { error: true, text: '拒绝回滚布局迁移:请显式加 --yes。' };
|
|
3051
|
+
if (!/^layout-[A-Za-z0-9-]+$/.test(String(opts.batch || ''))) return { error: true, text: '请用 --batch <id> 指定安全的布局迁移批次。' };
|
|
3052
|
+
const journalFile = path.join(root, LAYOUT_JOURNAL_DIR, opts.batch + '.json');
|
|
3053
|
+
let journal = null;
|
|
3054
|
+
try { journal = JSON.parse(fs.readFileSync(journalFile, 'utf8')); } catch (error) {
|
|
3055
|
+
return { error: true, text: '无法读取布局迁移 journal: ' + error.message };
|
|
3056
|
+
}
|
|
3057
|
+
if (journal.root !== root) return { error: true, text: 'journal root 与当前记忆库不一致。' };
|
|
3058
|
+
if (journal.status === 'rolled_back') return { error: false, batch: opts.batch, rolledBack: 0, journalFile: journalFile, text: '布局迁移批次已回滚,无需重复操作。' };
|
|
3059
|
+
if (journal.status !== 'applied') return { error: true, text: '批次状态不是 applied,不能常规回滚: ' + journal.status };
|
|
3060
|
+
return withLayoutLock(root, function () {
|
|
3061
|
+
let restored = 0;
|
|
3062
|
+
try {
|
|
3063
|
+
for (let i = journal.operations.length - 1; i >= 0; i--) {
|
|
3064
|
+
const op = journal.operations[i];
|
|
3065
|
+
if (op.status !== 'done') continue;
|
|
3066
|
+
const from = path.join(root, op.from);
|
|
3067
|
+
const to = path.join(root, op.to);
|
|
3068
|
+
if (!fs.existsSync(to)) throw new Error('迁移目标缺失: ' + op.to);
|
|
3069
|
+
if (fs.existsSync(from)) throw new Error('回滚目标已存在: ' + op.from);
|
|
3070
|
+
fs.mkdirSync(path.dirname(from), { recursive: true });
|
|
3071
|
+
fs.renameSync(to, from);
|
|
3072
|
+
op.status = 'rolled_back';
|
|
3073
|
+
restored += 1;
|
|
3074
|
+
writeLayoutJournal(journalFile, journal);
|
|
3075
|
+
}
|
|
3076
|
+
buildIndex(root);
|
|
3077
|
+
journal.status = 'rolled_back';
|
|
3078
|
+
journal.rolledBackAt = new Date().toISOString();
|
|
3079
|
+
writeLayoutJournal(journalFile, journal);
|
|
3080
|
+
return { error: false, batch: opts.batch, restored: restored, journalFile: journalFile, text: '布局迁移已回滚: ' + opts.batch + '(' + restored + ' 文件)' };
|
|
3081
|
+
} catch (error) {
|
|
3082
|
+
journal.status = 'rollback_failed';
|
|
3083
|
+
journal.rollbackError = error.message;
|
|
3084
|
+
writeLayoutJournal(journalFile, journal);
|
|
3085
|
+
return { error: true, batch: opts.batch, restored: restored, journalFile: journalFile, text: '布局回滚失败: ' + error.message };
|
|
3086
|
+
}
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
2537
3089
|
// 迁移:把根下旧平铺 prefs|bounds|commits/*.md 按 frontmatter owner 迁入 private/<owner>/<type>/
|
|
2538
3090
|
function migrateLayout(root) {
|
|
2539
3091
|
let moved = 0;
|
|
@@ -2804,15 +3356,13 @@ function touchIndex(root, relFiles, options) {
|
|
|
2804
3356
|
for (const sh of manifest.shards) {
|
|
2805
3357
|
if (!isSafeShardName(sh)) continue;
|
|
2806
3358
|
if (yearSet && !yearSet.has(shardYear(sh))) continue;
|
|
2807
|
-
const
|
|
2808
|
-
if (!
|
|
2809
|
-
let sd = null; try { sd = JSON.parse(fs.readFileSync(sp, 'utf8')); } catch (e) { continue; }
|
|
2810
|
-
if (!sd || !Array.isArray(sd.entries)) continue;
|
|
3359
|
+
const sd = readIndexShard(root, sh);
|
|
3360
|
+
if (!sd) continue;
|
|
2811
3361
|
let dirty = false;
|
|
2812
3362
|
for (const e of sd.entries) {
|
|
2813
3363
|
if (set.has(e.file)) { bumpEntry(e); dirty = true; }
|
|
2814
3364
|
}
|
|
2815
|
-
if (dirty)
|
|
3365
|
+
if (dirty) writeIndexShard(root, sh, sd);
|
|
2816
3366
|
}
|
|
2817
3367
|
});
|
|
2818
3368
|
return;
|
|
@@ -3429,6 +3979,53 @@ function backupSetupCore(opts) {
|
|
|
3429
3979
|
+ (schedule && schedule.error ? '\n[警告] ' + schedule.text + ';serve 补跑仍会在启动后检查。' : ''),
|
|
3430
3980
|
};
|
|
3431
3981
|
}
|
|
3982
|
+
const BACKUP_FUTURE_TOLERANCE_MS = 15 * 60 * 1000;
|
|
3983
|
+
function backupCreatedTs(value) {
|
|
3984
|
+
const ts = Date.parse(String(value || ''));
|
|
3985
|
+
return Number.isFinite(ts) ? ts : null;
|
|
3986
|
+
}
|
|
3987
|
+
function compareBackupCreatedDesc(a, b) {
|
|
3988
|
+
const at = backupCreatedTs(a && a.created);
|
|
3989
|
+
const bt = backupCreatedTs(b && b.created);
|
|
3990
|
+
if (at !== null && bt !== null && at !== bt) return bt - at;
|
|
3991
|
+
if (at !== null && bt === null) return -1;
|
|
3992
|
+
if (at === null && bt !== null) return 1;
|
|
3993
|
+
return String((b && b.id) || '').localeCompare(String((a && a.id) || ''));
|
|
3994
|
+
}
|
|
3995
|
+
function selectLatestValidBackup(backups, nowMs) {
|
|
3996
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
3997
|
+
const valid = [];
|
|
3998
|
+
const anomalies = [];
|
|
3999
|
+
for (const backup of backups || []) {
|
|
4000
|
+
if (!backup || !backup.manifest) {
|
|
4001
|
+
anomalies.push({ id: backup && backup.id || '', type: 'manifest_missing', created: backup && backup.created || '' });
|
|
4002
|
+
continue;
|
|
4003
|
+
}
|
|
4004
|
+
if (!Array.isArray(backup.manifest.files)) {
|
|
4005
|
+
anomalies.push({ id: backup.id, type: 'manifest_incomplete', created: backup.created || '' });
|
|
4006
|
+
continue;
|
|
4007
|
+
}
|
|
4008
|
+
const createdTs = backupCreatedTs(backup.created);
|
|
4009
|
+
if (createdTs === null) {
|
|
4010
|
+
anomalies.push({ id: backup.id, type: 'invalid_created', created: backup.created || '' });
|
|
4011
|
+
continue;
|
|
4012
|
+
}
|
|
4013
|
+
if (createdTs > now + BACKUP_FUTURE_TOLERANCE_MS) {
|
|
4014
|
+
anomalies.push({ id: backup.id, type: 'future_timestamp', created: backup.created || '' });
|
|
4015
|
+
continue;
|
|
4016
|
+
}
|
|
4017
|
+
valid.push(Object.assign({}, backup, { createdTs: createdTs }));
|
|
4018
|
+
}
|
|
4019
|
+
valid.sort(compareBackupCreatedDesc);
|
|
4020
|
+
return { latestValid: valid[0] || null, anomalies: anomalies };
|
|
4021
|
+
}
|
|
4022
|
+
function backupAnomalyLabel(type) {
|
|
4023
|
+
if (type === 'future_timestamp') return '未来时间';
|
|
4024
|
+
if (type === 'manifest_missing') return 'manifest 缺失';
|
|
4025
|
+
if (type === 'manifest_incomplete') return 'manifest 不完整';
|
|
4026
|
+
if (type === 'invalid_created') return 'created 无效';
|
|
4027
|
+
return type;
|
|
4028
|
+
}
|
|
3432
4029
|
function backupStatusCore(opts) {
|
|
3433
4030
|
opts = opts || {};
|
|
3434
4031
|
const root = path.resolve(opts.root || userRoot());
|
|
@@ -3450,14 +4047,20 @@ function backupStatusCore(opts) {
|
|
|
3450
4047
|
};
|
|
3451
4048
|
}
|
|
3452
4049
|
const listed = backupListCore({ dir: dir });
|
|
3453
|
-
const latest = listed.backups[0] || null;
|
|
3454
4050
|
const now = opts.now ? new Date(opts.now).getTime() : Date.now();
|
|
3455
|
-
const
|
|
3456
|
-
const
|
|
4051
|
+
const selection = selectLatestValidBackup(listed.backups, now);
|
|
4052
|
+
const latest = selection.latestValid;
|
|
4053
|
+
const ageHours = latest ? (now - latest.createdTs) / 3600000 : null;
|
|
3457
4054
|
const fresh = ageHours !== null && ageHours <= maxAge;
|
|
3458
4055
|
const same = opts.sameVolumeFn ? !!opts.sameVolumeFn(root, dir) : sameVolume(root, dir);
|
|
3459
4056
|
const independent = !same;
|
|
3460
4057
|
const healthy = !!latest && fresh && independent && !cfg.backup_last_error && !cfg.backup_scheduler_error;
|
|
4058
|
+
const configLastSuccess = cfg.backup_last_success || null;
|
|
4059
|
+
const effectiveLastSuccess = latest ? latest.created : configLastSuccess;
|
|
4060
|
+
const source = latest ? 'latest_valid' : (configLastSuccess ? 'config' : 'none');
|
|
4061
|
+
const anomalyLines = selection.anomalies.map(function (item) {
|
|
4062
|
+
return item.id + '(' + backupAnomalyLabel(item.type) + (item.created ? ' / ' + item.created : '') + ')';
|
|
4063
|
+
});
|
|
3461
4064
|
const lines = [
|
|
3462
4065
|
'备份状态: ' + (cfg.backup_enabled ? '已启用' : '已关闭'),
|
|
3463
4066
|
'- 备份目录: ' + dir,
|
|
@@ -3465,8 +4068,12 @@ function backupStatusCore(opts) {
|
|
|
3465
4068
|
'- 计划: ' + (cfg.backup_schedule || 'manual') + (cfg.backup_time ? ' ' + cfg.backup_time : ''),
|
|
3466
4069
|
'- 调度器: ' + (cfg.backup_scheduler || '(未注册)'),
|
|
3467
4070
|
'- 调度异常: ' + (cfg.backup_scheduler_error || '(无)'),
|
|
3468
|
-
'-
|
|
4071
|
+
'- 配置缓存上次成功: ' + (configLastSuccess || '(无)'),
|
|
4072
|
+
'- 有效备份: ' + (latest ? latest.id + ' / ' + latest.created : '(无)'),
|
|
4073
|
+
'- 有效上次成功: ' + (effectiveLastSuccess || '(无)'),
|
|
4074
|
+
'- 来源: ' + source,
|
|
3469
4075
|
'- 备份年龄: ' + (ageHours === null ? '(无可用备份)' : ageHours.toFixed(1) + ' 小时'),
|
|
4076
|
+
'- 异常备份: ' + (anomalyLines.length ? anomalyLines.join(';') : '(无)'),
|
|
3470
4077
|
'- 最近失败: ' + (cfg.backup_last_error || '(无)'),
|
|
3471
4078
|
'- 健康: ' + (healthy ? '正常' : '异常'),
|
|
3472
4079
|
];
|
|
@@ -3477,6 +4084,11 @@ function backupStatusCore(opts) {
|
|
|
3477
4084
|
healthy: healthy,
|
|
3478
4085
|
independent: independent,
|
|
3479
4086
|
latest: latest,
|
|
4087
|
+
latest_valid: latest,
|
|
4088
|
+
config_last_success: configLastSuccess,
|
|
4089
|
+
effective_last_success: effectiveLastSuccess,
|
|
4090
|
+
source: source,
|
|
4091
|
+
anomalies: selection.anomalies,
|
|
3480
4092
|
ageHours: ageHours,
|
|
3481
4093
|
text: lines.join('\n'),
|
|
3482
4094
|
};
|
|
@@ -3487,9 +4099,7 @@ function localDateKey(value) {
|
|
|
3487
4099
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
3488
4100
|
}
|
|
3489
4101
|
function latestBackupByCreated(backups) {
|
|
3490
|
-
return (backups || []).slice().sort(
|
|
3491
|
-
return String(b.created || '').localeCompare(String(a.created || ''));
|
|
3492
|
-
})[0] || null;
|
|
4102
|
+
return (backups || []).slice().sort(compareBackupCreatedDesc)[0] || null;
|
|
3493
4103
|
}
|
|
3494
4104
|
function acquireBackupLock(lockPath, now, staleHours) {
|
|
3495
4105
|
const nowMs = now instanceof Date ? now.getTime() : new Date(now).getTime();
|
|
@@ -3560,7 +4170,7 @@ function backupEnsureDailyCore(opts) {
|
|
|
3560
4170
|
const listed = backupListCore({ dir: dir });
|
|
3561
4171
|
if (listed.error) throw new Error(listed.text);
|
|
3562
4172
|
const todayKey = localDateKey(now);
|
|
3563
|
-
const latest =
|
|
4173
|
+
const latest = selectLatestValidBackup(listed.backups, now.getTime()).latestValid;
|
|
3564
4174
|
if (latest && latest.created && localDateKey(latest.created) === todayKey) {
|
|
3565
4175
|
cfg.backup_last_check = nowIso;
|
|
3566
4176
|
cfg.backup_last_success = latest.created;
|
|
@@ -3609,7 +4219,14 @@ function backupHealthCore(opts) {
|
|
|
3609
4219
|
const maxAge = Number(cfg.backup_max_age_hours) || 36;
|
|
3610
4220
|
if (cfg.backup_last_error) return { level: 'warning', needsSetup: false, text: '最近一次备份失败: ' + cfg.backup_last_error };
|
|
3611
4221
|
if (cfg.backup_scheduler_error) return { level: 'warning', needsSetup: false, text: '每日备份调度注册异常: ' + cfg.backup_scheduler_error };
|
|
3612
|
-
if (!status.
|
|
4222
|
+
if (!status.latest_valid) {
|
|
4223
|
+
const anomalies = (status.anomalies || []).map(function (item) { return item.id + '(' + item.type + ')'; });
|
|
4224
|
+
return {
|
|
4225
|
+
level: 'warning',
|
|
4226
|
+
needsSetup: true,
|
|
4227
|
+
text: '自动备份已启用,但没有可用备份。' + (anomalies.length ? '检测到异常备份: ' + anomalies.join(';') + '。' : '请检查 backup 目录与调度器。'),
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
3613
4230
|
if (status.ageHours !== null && status.ageHours > maxAge) {
|
|
3614
4231
|
return { level: 'warning', needsSetup: false, text: '最近备份已过期(' + status.ageHours.toFixed(1) + ' 小时,超过 ' + maxAge + ' 小时);请检查备份盘与每日调度。' };
|
|
3615
4232
|
}
|
|
@@ -4024,6 +4641,56 @@ function scaleMetric(key, value, unit, info, warn) {
|
|
|
4024
4641
|
const level = value > warn ? 'warning' : (value > info ? 'info' : 'ok');
|
|
4025
4642
|
return { key: key, value: value, unit: unit, info: info, warn: warn, level: level };
|
|
4026
4643
|
}
|
|
4644
|
+
function typePressureReport(entries, cfg) {
|
|
4645
|
+
const archiveUtility = parseFloat(cfg && cfg.maintain_archived_utility || ARCHIVE_UTILITY_DEFAULT);
|
|
4646
|
+
const archiveAge = parseInt(cfg && cfg.maintain_archived_age || '180', 10) || 180;
|
|
4647
|
+
const report = {};
|
|
4648
|
+
for (const entry of entries) {
|
|
4649
|
+
const type = String(entry.type || 'FACT').toUpperCase();
|
|
4650
|
+
if (!report[type]) {
|
|
4651
|
+
report[type] = {
|
|
4652
|
+
total: 0,
|
|
4653
|
+
stale_180d: 0,
|
|
4654
|
+
archive_candidates: 0,
|
|
4655
|
+
growth_30d: 0,
|
|
4656
|
+
stale_ratio: 0,
|
|
4657
|
+
growth_ratio_30d: 0,
|
|
4658
|
+
pressure_level: 'ok',
|
|
4659
|
+
observation_days: 7,
|
|
4660
|
+
final_thresholds_deferred: true,
|
|
4661
|
+
warnings: [],
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
const row = report[type];
|
|
4665
|
+
row.total += 1;
|
|
4666
|
+
const last = entry.last_accessed || entry.updated || entry.created || '';
|
|
4667
|
+
if (last && daysBetween(last, today()) > 180) row.stale_180d += 1;
|
|
4668
|
+
if (entry.created && daysBetween(entry.created, today()) <= 30) row.growth_30d += 1;
|
|
4669
|
+
const createdAge = entry.created ? daysBetween(entry.created, today()) : 0;
|
|
4670
|
+
if (type !== 'BOUND' && !entry.immutable && createdAge > archiveAge && utilityScore(entry) < archiveUtility) {
|
|
4671
|
+
row.archive_candidates += 1;
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
for (const type of Object.keys(report)) {
|
|
4675
|
+
const row = report[type];
|
|
4676
|
+
row.stale_ratio = row.total ? Math.round((row.stale_180d / row.total) * 1000) / 1000 : 0;
|
|
4677
|
+
const prior = Math.max(0, row.total - row.growth_30d);
|
|
4678
|
+
row.growth_ratio_30d = prior ? Math.round((row.growth_30d / prior) * 1000) / 1000 : (row.growth_30d ? 1 : 0);
|
|
4679
|
+
if (row.total > 5000) {
|
|
4680
|
+
row.pressure_level = 'warning';
|
|
4681
|
+
row.warnings.push('类型压力: ' + type + ' 总量 ' + row.total + ' 超过 5000,需要分类型瘦身或归档。');
|
|
4682
|
+
} else if (row.total > 500 && row.stale_ratio >= 0.3 && row.archive_candidates > 0) {
|
|
4683
|
+
row.pressure_level = 'warning';
|
|
4684
|
+
row.warnings.push('类型压力: ' + type + ' ' + row.total + ' 条中 ' + row.stale_180d
|
|
4685
|
+
+ ' 条超过 180 天未活跃,且有 ' + row.archive_candidates + ' 条归档候选。');
|
|
4686
|
+
} else if (row.total > 500 || row.growth_ratio_30d >= 0.2) {
|
|
4687
|
+
row.pressure_level = 'info';
|
|
4688
|
+
} else {
|
|
4689
|
+
row.pressure_level = 'observe';
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
return report;
|
|
4693
|
+
}
|
|
4027
4694
|
function scaleReport(root, cfg) {
|
|
4028
4695
|
const thresholds = {};
|
|
4029
4696
|
for (const key of Object.keys(SCALE_THRESHOLD_DEFAULTS)) thresholds[key] = scaleThreshold(cfg, key);
|
|
@@ -4032,6 +4699,13 @@ function scaleReport(root, cfg) {
|
|
|
4032
4699
|
const indexBytes = indexSizeBytes(root);
|
|
4033
4700
|
// v0.23.0 D3:超长 statement(> 2000 字)与单类型堆积(读索引,不逐条解密读文件)
|
|
4034
4701
|
const scaleEntries = loadIndex(root) || [];
|
|
4702
|
+
let indexData = null;
|
|
4703
|
+
try { indexData = JSON.parse(fs.readFileSync(indexPath(root), 'utf8')); } catch (e) { indexData = null; }
|
|
4704
|
+
const indexShards = indexData && Array.isArray(indexData.shards) ? indexData.shards.length : 0;
|
|
4705
|
+
const indexFormat = indexData
|
|
4706
|
+
? String(indexData.format || (Array.isArray(indexData.entries) ? 'inline' : (indexShards && indexData.shards.every(isGzipShardName) ? 'gzip-year' : 'json-year')))
|
|
4707
|
+
: 'missing';
|
|
4708
|
+
const indexLogicalBytes = jsonByteLength(scaleEntries);
|
|
4035
4709
|
let longStatements = 0;
|
|
4036
4710
|
const typeCounts = { FACT: 0, PREF: 0, BOUND: 0, COMMIT: 0 };
|
|
4037
4711
|
for (const e of scaleEntries) {
|
|
@@ -4049,6 +4723,7 @@ function scaleReport(root, cfg) {
|
|
|
4049
4723
|
for (let i = 0; i < 3; i++) samples.push(measureIndexColdStart(root));
|
|
4050
4724
|
samples.sort(function (a, b) { return a - b; });
|
|
4051
4725
|
const coldStartMs = samples[Math.floor(samples.length / 2)];
|
|
4726
|
+
const typePressure = typePressureReport(scaleEntries, cfg);
|
|
4052
4727
|
const metrics = [
|
|
4053
4728
|
scaleMetric('entries', entries, '条', thresholds.scale_info_entries, thresholds.scale_warn_entries),
|
|
4054
4729
|
scaleMetric('files_per_dir', filesPerDir, '个文件', thresholds.scale_info_files_per_dir, thresholds.scale_warn_files_per_dir),
|
|
@@ -4076,17 +4751,25 @@ function scaleReport(root, cfg) {
|
|
|
4076
4751
|
if (metrics[5].level === 'warning') {
|
|
4077
4752
|
warnings.push('规模: 单类型堆积(' + typeMaxName + ' ' + typeMax + ' 条)超过阈值 ' + thresholds.scale_warn_type_count + '(考虑归档 / 合并 / 精简;COMMIT 收工锚点建议短写)');
|
|
4078
4753
|
}
|
|
4754
|
+
for (const type of Object.keys(typePressure)) {
|
|
4755
|
+
for (const message of typePressure[type].warnings) warnings.push(message);
|
|
4756
|
+
}
|
|
4079
4757
|
// v0.18.0 A6:分级只影响 level 与提示文案;超 warn 仍进 warnings(既有行为),info 不阻断。
|
|
4080
|
-
const
|
|
4758
|
+
const pressureWarning = Object.keys(typePressure).some(function (type) { return typePressure[type].pressure_level === 'warning'; });
|
|
4759
|
+
const level = pressureWarning || metrics.some(function (m) { return m.level === 'warning'; })
|
|
4081
4760
|
? 'warning'
|
|
4082
4761
|
: (metrics.some(function (m) { return m.level === 'info'; }) ? 'info' : 'ok');
|
|
4083
4762
|
return {
|
|
4084
4763
|
entries: entries,
|
|
4085
4764
|
files_per_dir: filesPerDir,
|
|
4086
4765
|
index_bytes: indexBytes,
|
|
4766
|
+
index_logical_bytes: indexLogicalBytes,
|
|
4767
|
+
index_shards: indexShards,
|
|
4768
|
+
index_format: indexFormat,
|
|
4087
4769
|
cold_start_ms: coldStartMs,
|
|
4088
4770
|
thresholds: thresholds,
|
|
4089
4771
|
metrics: metrics,
|
|
4772
|
+
type_pressure: typePressure,
|
|
4090
4773
|
level: level,
|
|
4091
4774
|
warnings: warnings,
|
|
4092
4775
|
};
|
|
@@ -4411,7 +5094,7 @@ function isAllowedBackupPath(rel) {
|
|
|
4411
5094
|
if (!normalized) return true;
|
|
4412
5095
|
const top = normalized.split('/')[0];
|
|
4413
5096
|
if (top === 'facts' || top === 'private' || top === '.archive') return true;
|
|
4414
|
-
if (top === 'agents.json' || top === 'index.json' || /^index-\d{4}\.json
|
|
5097
|
+
if (top === 'agents.json' || top === 'index.json' || /^index-\d{4}\.json(?:\.gz)?$/.test(top)) return true;
|
|
4415
5098
|
if (top === 'keys') {
|
|
4416
5099
|
return normalized !== 'keys/cache' && normalized.indexOf('keys/cache/') !== 0 &&
|
|
4417
5100
|
normalized !== 'keys/pending' && normalized.indexOf('keys/pending/') !== 0;
|
|
@@ -4475,7 +5158,7 @@ function backupListCore(opts) {
|
|
|
4475
5158
|
manifest: manifest,
|
|
4476
5159
|
});
|
|
4477
5160
|
}
|
|
4478
|
-
backups.sort(
|
|
5161
|
+
backups.sort(compareBackupCreatedDesc);
|
|
4479
5162
|
const lines = backups.length
|
|
4480
5163
|
? backups.map(function (b) { return '- ' + b.id + ' ' + (b.created || '?') + ' ' + (b.files === null ? 'manifest 缺失' : b.files + ' 文件'); })
|
|
4481
5164
|
: ['(无备份)'];
|
|
@@ -4485,7 +5168,7 @@ function backupDoctorCore(opts) {
|
|
|
4485
5168
|
opts = opts || {};
|
|
4486
5169
|
const listed = backupListCore(opts);
|
|
4487
5170
|
if (listed.error) return { error: true, ok: false, text: listed.text };
|
|
4488
|
-
const id = opts.id || (listed.backups
|
|
5171
|
+
const id = opts.id || (selectLatestValidBackup(listed.backups, Date.now()).latestValid || {}).id;
|
|
4489
5172
|
if (!id) return { error: false, ok: false, text: '没有可检查的备份。' };
|
|
4490
5173
|
const backup = listed.backups.find(function (item) { return item.id === id; });
|
|
4491
5174
|
if (!backup) return { error: true, ok: false, text: '未找到备份: ' + id };
|
|
@@ -7849,6 +8532,29 @@ function cmdBackupDoctor(opts) {
|
|
|
7849
8532
|
console.log(r.text);
|
|
7850
8533
|
if (r.error || !r.ok) process.exit(2);
|
|
7851
8534
|
}
|
|
8535
|
+
function cmdIndex(opts) {
|
|
8536
|
+
if (!opts.stats) {
|
|
8537
|
+
console.error('index 只提供只读统计:请使用 yotta-memory index --stats [--json]。');
|
|
8538
|
+
process.exit(2);
|
|
8539
|
+
}
|
|
8540
|
+
const r = indexStatsCore(opts);
|
|
8541
|
+
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
8542
|
+
else console.log(r.text);
|
|
8543
|
+
if (r.error) process.exit(2);
|
|
8544
|
+
}
|
|
8545
|
+
function cmdLayout(action, opts) {
|
|
8546
|
+
let r;
|
|
8547
|
+
if (action === 'plan') r = layoutPlanCore(opts);
|
|
8548
|
+
else if (action === 'apply') r = layoutApplyCore(Object.assign({}, opts, { planFile: opts.fromPlan }));
|
|
8549
|
+
else if (action === 'rollback') r = layoutRollbackCore(opts);
|
|
8550
|
+
else {
|
|
8551
|
+
console.error('layout 子命令: plan [--threshold N] [--include-flat] [--out <文件>] / apply --from-plan <文件> --yes / rollback --batch <id> --yes');
|
|
8552
|
+
process.exit(2);
|
|
8553
|
+
}
|
|
8554
|
+
if (opts.json) console.log(JSON.stringify(r, null, 2));
|
|
8555
|
+
else console.log(r.text);
|
|
8556
|
+
if (r.error) process.exit(2);
|
|
8557
|
+
}
|
|
7852
8558
|
function cmdBackupRestore(id, opts) {
|
|
7853
8559
|
const r = backupRestoreCore(id, opts);
|
|
7854
8560
|
console.log(r.text);
|
|
@@ -9915,6 +10621,7 @@ const SCAN_SEVERITIES = ['safe', 'low', 'medium', 'high', 'critical'];
|
|
|
9915
10621
|
const SCAN_MAX_FINDINGS = 500;
|
|
9916
10622
|
const SCAN_MAX_FILES = 20000;
|
|
9917
10623
|
const SCAN_QUARANTINE_DIR = '.memory-scan';
|
|
10624
|
+
const SCAN_POLICY_FILE = 'security/scan-policy.json';
|
|
9918
10625
|
const MEMORY_SCAN_RULES = [
|
|
9919
10626
|
// ── 恶意指令(元安 DownloadExec / 元盾危险命令)──
|
|
9920
10627
|
{ id: 'YTM-MAL-001', class: 'malicious-instruction', severity: 'critical', ref: 'DEX-001@yotta-security-audit', title: 'curl 下载内容交给 shell 执行', mask: false, re: /\bcurl\b[^\n|;]{0,120}\|\s*(?:ba)?sh\b/i },
|
|
@@ -9966,7 +10673,77 @@ function scanEmptySummary() {
|
|
|
9966
10673
|
for (const cls of SCAN_CLASSES) byClass[cls] = 0;
|
|
9967
10674
|
const bySeverity = {};
|
|
9968
10675
|
for (const sev of SCAN_SEVERITIES) bySeverity[sev] = 0;
|
|
9969
|
-
return {
|
|
10676
|
+
return {
|
|
10677
|
+
files: 0,
|
|
10678
|
+
encrypted: 0,
|
|
10679
|
+
mislabelled: 0,
|
|
10680
|
+
scanned: 0,
|
|
10681
|
+
hits: 0,
|
|
10682
|
+
suppressed: 0,
|
|
10683
|
+
invalid_policy: 0,
|
|
10684
|
+
expired_policy: 0,
|
|
10685
|
+
byClass: byClass,
|
|
10686
|
+
bySeverity: bySeverity,
|
|
10687
|
+
maxSeverity: 'safe',
|
|
10688
|
+
truncated: false,
|
|
10689
|
+
};
|
|
10690
|
+
}
|
|
10691
|
+
function scanLineHash(line) {
|
|
10692
|
+
return crypto.createHash('sha256').update(String(line || ''), 'utf8').digest('hex');
|
|
10693
|
+
}
|
|
10694
|
+
function loadScanPolicy(root, noPolicy) {
|
|
10695
|
+
const pathname = path.join(root, SCAN_POLICY_FILE);
|
|
10696
|
+
if (noPolicy) {
|
|
10697
|
+
return { path: pathname, entries: [], map: new Map(), invalid: 0, expired: 0, used: new Set(), warnings: [] };
|
|
10698
|
+
}
|
|
10699
|
+
if (!fs.existsSync(pathname)) {
|
|
10700
|
+
return { path: pathname, entries: [], map: new Map(), invalid: 0, expired: 0, used: new Set(), warnings: [] };
|
|
10701
|
+
}
|
|
10702
|
+
let data = null;
|
|
10703
|
+
try { data = JSON.parse(fs.readFileSync(pathname, 'utf8')); } catch (error) {
|
|
10704
|
+
return { path: pathname, entries: [], map: new Map(), invalid: 1, expired: 0, used: new Set(), warnings: ['扫描策略 JSON 无法解析: ' + error.message] };
|
|
10705
|
+
}
|
|
10706
|
+
const rawEntries = data && Array.isArray(data.entries) ? data.entries : [];
|
|
10707
|
+
const entries = [];
|
|
10708
|
+
const map = new Map();
|
|
10709
|
+
let invalid = data && Array.isArray(data.entries) ? 0 : 1;
|
|
10710
|
+
let expired = 0;
|
|
10711
|
+
const warnings = [];
|
|
10712
|
+
if (!data || !Array.isArray(data.entries)) warnings.push('扫描策略结构无效:缺少 entries 数组。');
|
|
10713
|
+
for (const raw of rawEntries) {
|
|
10714
|
+
const rule = String(raw && raw.rule || '').trim();
|
|
10715
|
+
const file = String(raw && raw.file || '').replace(/\\/g, '/');
|
|
10716
|
+
const hash = String(raw && raw.line_sha256 || '').trim().toLowerCase();
|
|
10717
|
+
const reviewBy = String(raw && raw.review_by || '').trim();
|
|
10718
|
+
const reason = String(raw && raw.reason || '').trim();
|
|
10719
|
+
const valid = /^YTM-[A-Z]+-\d{3}$/.test(rule) &&
|
|
10720
|
+
isSafeRelPath(file) && file.indexOf('*') === -1 && MEMORY_FILE_RE.test(file) &&
|
|
10721
|
+
/^[a-f0-9]{64}$/.test(hash) &&
|
|
10722
|
+
/^\d{4}-\d{2}-\d{2}$/.test(reviewBy) && !isNaN(new Date(reviewBy).getTime()) &&
|
|
10723
|
+
reason.length > 0;
|
|
10724
|
+
if (!valid) {
|
|
10725
|
+
invalid++;
|
|
10726
|
+
continue;
|
|
10727
|
+
}
|
|
10728
|
+
if (reviewBy < today()) {
|
|
10729
|
+
expired++;
|
|
10730
|
+
continue;
|
|
10731
|
+
}
|
|
10732
|
+
const entry = {
|
|
10733
|
+
rule: rule,
|
|
10734
|
+
file: file,
|
|
10735
|
+
line_sha256: hash,
|
|
10736
|
+
reason: reason,
|
|
10737
|
+
review_by: reviewBy,
|
|
10738
|
+
added_by: String(raw.added_by || ''),
|
|
10739
|
+
added_at: String(raw.added_at || ''),
|
|
10740
|
+
};
|
|
10741
|
+
entries.push(entry);
|
|
10742
|
+
map.set(rule + '\0' + file + '\0' + hash, entry);
|
|
10743
|
+
}
|
|
10744
|
+
if (invalid) warnings.push('扫描策略有 ' + invalid + ' 条无效条目,已 fail-closed 忽略。');
|
|
10745
|
+
if (expired) warnings.push('扫描策略有 ' + expired + ' 条已过期,已恢复为命中。');
|
|
10746
|
+
return { path: pathname, entries: entries, map: map, invalid: invalid, expired: expired, used: new Set(), warnings: warnings };
|
|
9970
10747
|
}
|
|
9971
10748
|
function isSafeRelPath(rel) {
|
|
9972
10749
|
const value = String(rel || '');
|
|
@@ -10108,10 +10885,16 @@ function renderScanText(report) {
|
|
|
10108
10885
|
'',
|
|
10109
10886
|
'- 扫描路径: ' + report.root,
|
|
10110
10887
|
'- 扫描文件: ' + report.summary.files + '(加密跳过 ' + report.summary.encrypted + (report.summary.mislabelled ? ';.enc 后缀但内容是明文 ' + report.summary.mislabelled + ' 个已按文本扫描' : '') + ')',
|
|
10111
|
-
'- 结果: ' + label[report.level] + ' · 命中 ' + report.summary.hits + ' 条'
|
|
10888
|
+
'- 结果: ' + label[report.level] + ' · 命中 ' + report.summary.hits + ' 条'
|
|
10889
|
+
+ (report.summary.suppressed ? ' · 已抑制 ' + report.summary.suppressed + ' 条' : '')
|
|
10890
|
+
+ (report.summary.truncated ? '(已达上限,结果被截断)' : ''),
|
|
10112
10891
|
'- 分级: ' + SCAN_SEVERITIES.map(function (s) { return s + '=' + report.summary.bySeverity[s]; }).join(' / '),
|
|
10113
10892
|
'- 类别: ' + SCAN_CLASSES.map(function (c) { return c + '=' + report.summary.byClass[c]; }).join(' / '),
|
|
10114
10893
|
];
|
|
10894
|
+
if (report.policy && (report.policy.invalid || report.policy.expired || report.policy.unused)) {
|
|
10895
|
+
lines.push('- 策略: 无效 ' + report.policy.invalid + ' / 过期 ' + report.policy.expired + ' / 未命中 ' + report.policy.unused);
|
|
10896
|
+
}
|
|
10897
|
+
for (const warning of (report.policy && report.policy.warnings) || []) lines.push('- [策略警告] ' + warning);
|
|
10115
10898
|
if (report.gate) lines.push('- 门禁: 命中 ' + report.gate + ' 及以上即 exit 1(当前 ' + report.level + ')');
|
|
10116
10899
|
if (!report.findings.length) lines.push('- 未发现可疑内容。');
|
|
10117
10900
|
for (const finding of report.findings) {
|
|
@@ -10149,6 +10932,8 @@ function scanCore(opts) {
|
|
|
10149
10932
|
if (opts.restore) return scanRestoreCore(root, opts);
|
|
10150
10933
|
|
|
10151
10934
|
const findings = [];
|
|
10935
|
+
const suppressed = [];
|
|
10936
|
+
const policy = loadScanPolicy(root, opts.noPolicy);
|
|
10152
10937
|
const byClass = {};
|
|
10153
10938
|
for (const cls of SCAN_CLASSES) byClass[cls] = 0;
|
|
10154
10939
|
const bySeverity = {};
|
|
@@ -10182,6 +10967,25 @@ function scanCore(opts) {
|
|
|
10182
10967
|
const start = Math.max(0, match.index - 40);
|
|
10183
10968
|
const end = Math.min(line.length, match.index + match[0].length + 40);
|
|
10184
10969
|
const snippet = scrubSecrets((start > 0 ? '…' : '') + line.slice(start, end) + (end < line.length ? '…' : '')).slice(0, 200);
|
|
10970
|
+
const lineHash = scanLineHash(line);
|
|
10971
|
+
const policyKey = rule.id + '\0' + rel + '\0' + lineHash;
|
|
10972
|
+
if (policy.map.has(policyKey)) {
|
|
10973
|
+
const exception = policy.map.get(policyKey);
|
|
10974
|
+
policy.used.add(policyKey);
|
|
10975
|
+
suppressed.push({
|
|
10976
|
+
file: rel,
|
|
10977
|
+
line: i + 1,
|
|
10978
|
+
evidence: rel + ':' + (i + 1),
|
|
10979
|
+
rule: rule.id,
|
|
10980
|
+
class: rule.class,
|
|
10981
|
+
severity: rule.severity,
|
|
10982
|
+
title: rule.title,
|
|
10983
|
+
line_sha256: lineHash,
|
|
10984
|
+
reason: exception.reason,
|
|
10985
|
+
review_by: exception.review_by,
|
|
10986
|
+
});
|
|
10987
|
+
continue;
|
|
10988
|
+
}
|
|
10185
10989
|
findings.push({
|
|
10186
10990
|
file: rel,
|
|
10187
10991
|
line: i + 1,
|
|
@@ -10191,6 +10995,7 @@ function scanCore(opts) {
|
|
|
10191
10995
|
severity: rule.severity,
|
|
10192
10996
|
title: rule.title,
|
|
10193
10997
|
ref: rule.ref,
|
|
10998
|
+
line_sha256: lineHash,
|
|
10194
10999
|
match: scrubSecrets(match[0]).slice(0, 80),
|
|
10195
11000
|
snippet: snippet,
|
|
10196
11001
|
});
|
|
@@ -10213,6 +11018,9 @@ function scanCore(opts) {
|
|
|
10213
11018
|
bySeverity: bySeverity,
|
|
10214
11019
|
maxSeverity: level,
|
|
10215
11020
|
truncated: truncated,
|
|
11021
|
+
suppressed: suppressed.length,
|
|
11022
|
+
invalid_policy: policy.invalid,
|
|
11023
|
+
expired_policy: policy.expired,
|
|
10216
11024
|
};
|
|
10217
11025
|
const report = {
|
|
10218
11026
|
schemaVersion: SCAN_SCHEMA_VERSION,
|
|
@@ -10223,7 +11031,16 @@ function scanCore(opts) {
|
|
|
10223
11031
|
root: root,
|
|
10224
11032
|
gate: gate,
|
|
10225
11033
|
findings: findings,
|
|
11034
|
+
suppressed: suppressed,
|
|
10226
11035
|
summary: summary,
|
|
11036
|
+
policy: {
|
|
11037
|
+
path: policy.path,
|
|
11038
|
+
entries: policy.entries.length,
|
|
11039
|
+
invalid: policy.invalid,
|
|
11040
|
+
expired: policy.expired,
|
|
11041
|
+
unused: Math.max(0, policy.entries.length - policy.used.size),
|
|
11042
|
+
warnings: policy.warnings,
|
|
11043
|
+
},
|
|
10227
11044
|
quarantine: null,
|
|
10228
11045
|
restore: null,
|
|
10229
11046
|
};
|
|
@@ -10252,6 +11069,64 @@ function scanCore(opts) {
|
|
|
10252
11069
|
}
|
|
10253
11070
|
return report;
|
|
10254
11071
|
}
|
|
11072
|
+
function scanPolicyCandidatesCore(opts) {
|
|
11073
|
+
opts = opts || {};
|
|
11074
|
+
const root = path.resolve(opts.path || opts.root || userRoot());
|
|
11075
|
+
const scan = scanCore({
|
|
11076
|
+
root: root,
|
|
11077
|
+
noPolicy: true,
|
|
11078
|
+
quarantine: false,
|
|
11079
|
+
restore: false,
|
|
11080
|
+
gate: '',
|
|
11081
|
+
});
|
|
11082
|
+
if (scan.error) return scan;
|
|
11083
|
+
const seen = new Set();
|
|
11084
|
+
const candidates = [];
|
|
11085
|
+
for (const finding of scan.findings) {
|
|
11086
|
+
const key = finding.rule + '\0' + finding.file + '\0' + finding.line_sha256;
|
|
11087
|
+
if (seen.has(key)) continue;
|
|
11088
|
+
seen.add(key);
|
|
11089
|
+
candidates.push({
|
|
11090
|
+
rule: finding.rule,
|
|
11091
|
+
file: finding.file,
|
|
11092
|
+
line: finding.line,
|
|
11093
|
+
line_sha256: finding.line_sha256,
|
|
11094
|
+
class: finding.class,
|
|
11095
|
+
severity: finding.severity,
|
|
11096
|
+
title: finding.title,
|
|
11097
|
+
reason: '',
|
|
11098
|
+
review_by: '',
|
|
11099
|
+
added_by: '',
|
|
11100
|
+
added_at: '',
|
|
11101
|
+
});
|
|
11102
|
+
}
|
|
11103
|
+
let outPath = '';
|
|
11104
|
+
if (opts.out) {
|
|
11105
|
+
const rel = String(opts.out).replace(/\\/g, '/');
|
|
11106
|
+
if (!layoutInsideRoot(root, rel)) {
|
|
11107
|
+
return {
|
|
11108
|
+
schemaVersion: SCAN_SCHEMA_VERSION,
|
|
11109
|
+
error: true,
|
|
11110
|
+
exitCode: 2,
|
|
11111
|
+
candidates: [],
|
|
11112
|
+
text: '拒绝写出策略候选:--out 必须位于当前记忆库内。',
|
|
11113
|
+
};
|
|
11114
|
+
}
|
|
11115
|
+
outPath = path.resolve(root, rel);
|
|
11116
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
11117
|
+
atomicWriteFileSync(outPath, JSON.stringify({ version: 1, candidates: candidates }, null, 2), 'utf8');
|
|
11118
|
+
}
|
|
11119
|
+
return {
|
|
11120
|
+
schemaVersion: SCAN_SCHEMA_VERSION,
|
|
11121
|
+
error: false,
|
|
11122
|
+
exitCode: 0,
|
|
11123
|
+
root: root,
|
|
11124
|
+
candidates: candidates,
|
|
11125
|
+
out: outPath,
|
|
11126
|
+
text: '扫描策略候选: ' + candidates.length + ' 条'
|
|
11127
|
+
+ (outPath ? '\n已写出: ' + outPath : '\n未写文件;请人工复核后配置 security/scan-policy.json。'),
|
|
11128
|
+
};
|
|
11129
|
+
}
|
|
10255
11130
|
// 交互确认:非 TTY 一律返回 null(由调用方给 --yes 指引),避免自动化里静默改写记忆。
|
|
10256
11131
|
function promptYesNo(promptText) {
|
|
10257
11132
|
return new Promise(function (resolve) {
|
|
@@ -10283,6 +11158,12 @@ function promptYesNo(promptText) {
|
|
|
10283
11158
|
}
|
|
10284
11159
|
async function cmdScan(opts) {
|
|
10285
11160
|
opts = opts || {};
|
|
11161
|
+
if (opts.policyCandidates) {
|
|
11162
|
+
const candidates = scanPolicyCandidatesCore(opts);
|
|
11163
|
+
console.log(opts.json ? JSON.stringify(candidates, null, 2) : candidates.text);
|
|
11164
|
+
if (candidates.error) process.exit(candidates.exitCode || 2);
|
|
11165
|
+
return;
|
|
11166
|
+
}
|
|
10286
11167
|
if (opts.quarantine && !opts.yes) {
|
|
10287
11168
|
const preview = scanCore(Object.assign({}, opts, { quarantine: false }));
|
|
10288
11169
|
if (preview.error) {
|
|
@@ -10743,6 +11624,7 @@ function cmdServe(opts) {
|
|
|
10743
11624
|
server.listen(port, host, function () {
|
|
10744
11625
|
console.log('yotta-memory 记忆引擎已启动(v' + VERSION + ')');
|
|
10745
11626
|
console.log('URL: http://' + host + ':' + port + '/mcp');
|
|
11627
|
+
if (opts.profile) console.log('服务 profile: ' + path.resolve(String(opts.profile)));
|
|
10746
11628
|
console.log('记忆库: ' + root);
|
|
10747
11629
|
console.log('MCP 协议: 2026-07-28(modern,server/discover)/ 2025-11-25(legacy 握手兼容)');
|
|
10748
11630
|
console.log('工具分组: ' + normalizeMcpToolProfile(opts.toolProfile));
|
|
@@ -10811,18 +11693,144 @@ const LAN_TASK_NAME = 'YottaMemoryServe';
|
|
|
10811
11693
|
const LAN_GEN_MARKER = 'Generated by yotta-memory lan enable';
|
|
10812
11694
|
const LAN_UNIT_NAME = 'yotta-memory-serve.service';
|
|
10813
11695
|
const LAN_CRONTAB_MARKER = '#YTM_LAN:yotta-memory-serve';
|
|
11696
|
+
const SERVE_PROFILE_FILE = 'serve-profile.json';
|
|
11697
|
+
const SERVE_PROFILE_SCHEMA = 1;
|
|
11698
|
+
|
|
11699
|
+
function resolveProfilePath(value) {
|
|
11700
|
+
const text = String(value || '');
|
|
11701
|
+
if (!text) return '';
|
|
11702
|
+
if (path.isAbsolute(text)) return path.resolve(text);
|
|
11703
|
+
if (path.win32.isAbsolute(text)) return path.win32.normalize(text);
|
|
11704
|
+
return path.resolve(text);
|
|
11705
|
+
}
|
|
11706
|
+
|
|
11707
|
+
function lanServeProfilePath(opts) {
|
|
11708
|
+
opts = opts || {};
|
|
11709
|
+
if (opts.profilePath) return resolveProfilePath(opts.profilePath);
|
|
11710
|
+
if (process.env.YOTTA_LAN_PROFILE_FILE) return resolveProfilePath(process.env.YOTTA_LAN_PROFILE_FILE);
|
|
11711
|
+
const configDir = opts.configDir || process.env.YOTTA_MEMORY_CONFIG_DIR || path.dirname(configPath());
|
|
11712
|
+
return path.join(path.resolve(String(configDir)), SERVE_PROFILE_FILE);
|
|
11713
|
+
}
|
|
11714
|
+
|
|
11715
|
+
function lanCurrentUser(opts) {
|
|
11716
|
+
opts = opts || {};
|
|
11717
|
+
if (opts.currentUser) return String(opts.currentUser);
|
|
11718
|
+
if (lanPlatform() === 'win32') return lanWindowsTaskUser(opts);
|
|
11719
|
+
try {
|
|
11720
|
+
const info = os.userInfo();
|
|
11721
|
+
return String(info.username || '');
|
|
11722
|
+
} catch (e) {
|
|
11723
|
+
return '';
|
|
11724
|
+
}
|
|
11725
|
+
}
|
|
11726
|
+
|
|
11727
|
+
function sameLanUser(a, b) {
|
|
11728
|
+
const left = String(a || '').trim().toLowerCase();
|
|
11729
|
+
const right = String(b || '').trim().toLowerCase();
|
|
11730
|
+
return !left || !right || left === right;
|
|
11731
|
+
}
|
|
11732
|
+
|
|
11733
|
+
function lanWriteServeProfile(opts) {
|
|
11734
|
+
opts = opts || {};
|
|
11735
|
+
try {
|
|
11736
|
+
const memoryHome = path.resolve(String(opts.memoryHome || userRoot()));
|
|
11737
|
+
const runtimeHome = path.resolve(String(opts.runtimeHome || runtimeRoot()));
|
|
11738
|
+
const configDir = path.resolve(String(opts.configDir || process.env.YOTTA_MEMORY_CONFIG_DIR || path.dirname(configPath())));
|
|
11739
|
+
const profilePath = lanServeProfilePath(Object.assign({}, opts, { configDir: configDir }));
|
|
11740
|
+
if (!fs.existsSync(memoryHome) || !fs.statSync(memoryHome).isDirectory()) {
|
|
11741
|
+
return { error: true, text: '记忆库不存在或不是目录: ' + memoryHome + '。请先初始化或修正 memory_home。' };
|
|
11742
|
+
}
|
|
11743
|
+
const profile = {
|
|
11744
|
+
schemaVersion: SERVE_PROFILE_SCHEMA,
|
|
11745
|
+
generatedAt: new Date().toISOString(),
|
|
11746
|
+
userId: String(opts.userId || lanCurrentUser(opts)),
|
|
11747
|
+
memoryHome: memoryHome,
|
|
11748
|
+
runtimeHome: runtimeHome,
|
|
11749
|
+
configDir: configDir,
|
|
11750
|
+
host: String(opts.host || '0.0.0.0'),
|
|
11751
|
+
port: parseInt(opts.port, 10) || 8787,
|
|
11752
|
+
};
|
|
11753
|
+
fs.mkdirSync(path.dirname(profilePath), { recursive: true });
|
|
11754
|
+
atomicWriteFileSync(profilePath, JSON.stringify(profile, null, 2) + '\n', 'utf8');
|
|
11755
|
+
return { error: false, profilePath: profilePath, profile: profile };
|
|
11756
|
+
} catch (e) {
|
|
11757
|
+
return { error: true, text: '写入服务 profile 失败: ' + String((e && e.message) || e) };
|
|
11758
|
+
}
|
|
11759
|
+
}
|
|
11760
|
+
|
|
11761
|
+
function loadServeProfile(profilePath, opts) {
|
|
11762
|
+
opts = opts || {};
|
|
11763
|
+
const resolved = resolveProfilePath(profilePath);
|
|
11764
|
+
try {
|
|
11765
|
+
if (!profilePath) return { error: true, text: 'profile 路径不能为空。' };
|
|
11766
|
+
const profile = JSON.parse(String(fs.readFileSync(resolved, 'utf8')).replace(/^\uFEFF/, ''));
|
|
11767
|
+
if (!profile || profile.schemaVersion !== SERVE_PROFILE_SCHEMA) {
|
|
11768
|
+
return { error: true, text: 'profile schemaVersion 无效,需要 ' + SERVE_PROFILE_SCHEMA + '。' };
|
|
11769
|
+
}
|
|
11770
|
+
for (const key of ['memoryHome', 'runtimeHome', 'configDir']) {
|
|
11771
|
+
const value = String(profile[key] || '').trim();
|
|
11772
|
+
if (!value || !path.isAbsolute(value)) {
|
|
11773
|
+
return { error: true, text: 'profile 字段 ' + key + ' 必须是绝对路径。' };
|
|
11774
|
+
}
|
|
11775
|
+
}
|
|
11776
|
+
const memoryHome = path.resolve(String(profile.memoryHome));
|
|
11777
|
+
const runtimeHome = path.resolve(String(profile.runtimeHome));
|
|
11778
|
+
const configDir = path.resolve(String(profile.configDir));
|
|
11779
|
+
if (!fs.existsSync(memoryHome) || !fs.statSync(memoryHome).isDirectory()) {
|
|
11780
|
+
return { error: true, text: 'profile memoryHome 指定的记忆库不存在或不是目录: ' + memoryHome };
|
|
11781
|
+
}
|
|
11782
|
+
if (!fs.existsSync(runtimeHome) || !fs.statSync(runtimeHome).isDirectory()) {
|
|
11783
|
+
return { error: true, text: 'profile runtimeHome 指定的运行时目录不存在或不是目录: ' + runtimeHome };
|
|
11784
|
+
}
|
|
11785
|
+
const currentUser = opts.currentUser || lanCurrentUser(opts);
|
|
11786
|
+
if (!sameLanUser(profile.userId, currentUser)) {
|
|
11787
|
+
return { error: true, text: 'profile userId 与当前用户不匹配: ' + String(profile.userId || '(empty)') + ' != ' + String(currentUser || '(empty)') };
|
|
11788
|
+
}
|
|
11789
|
+
return {
|
|
11790
|
+
error: false,
|
|
11791
|
+
profilePath: resolved,
|
|
11792
|
+
profile: Object.assign({}, profile, {
|
|
11793
|
+
memoryHome: memoryHome,
|
|
11794
|
+
runtimeHome: runtimeHome,
|
|
11795
|
+
configDir: configDir,
|
|
11796
|
+
}),
|
|
11797
|
+
};
|
|
11798
|
+
} catch (e) {
|
|
11799
|
+
return { error: true, text: '无法读取服务 profile ' + resolved + ': ' + String((e && e.message) || e) };
|
|
11800
|
+
}
|
|
11801
|
+
}
|
|
11802
|
+
|
|
11803
|
+
function applyServeProfile(profilePath, opts) {
|
|
11804
|
+
const loaded = loadServeProfile(profilePath, opts);
|
|
11805
|
+
if (loaded.error) return loaded;
|
|
11806
|
+
process.env.YOTTA_MEMORY_HOME = loaded.profile.memoryHome;
|
|
11807
|
+
process.env.YOTTA_MEMORY_RUNTIME_HOME = loaded.profile.runtimeHome;
|
|
11808
|
+
process.env.YOTTA_MEMORY_CONFIG_DIR = loaded.profile.configDir;
|
|
11809
|
+
return loaded;
|
|
11810
|
+
}
|
|
11811
|
+
|
|
11812
|
+
function serveProfileDrift(profile) {
|
|
11813
|
+
if (!profile) return [];
|
|
11814
|
+
const drift = [];
|
|
11815
|
+
if (path.resolve(String(profile.memoryHome || '')) !== path.resolve(userRoot())) drift.push('memoryHome');
|
|
11816
|
+
if (path.resolve(String(profile.runtimeHome || '')) !== path.resolve(runtimeRoot())) drift.push('runtimeHome');
|
|
11817
|
+
if (path.resolve(String(profile.configDir || '')) !== path.resolve(path.dirname(configPath()))) drift.push('configDir');
|
|
11818
|
+
return drift;
|
|
11819
|
+
}
|
|
11820
|
+
|
|
10814
11821
|
function lanTaskRunCmd(opts) {
|
|
10815
11822
|
opts = opts || {};
|
|
10816
11823
|
const host = opts.host || '0.0.0.0';
|
|
10817
11824
|
const port = opts.port || 8787;
|
|
11825
|
+
const profilePath = lanServeProfilePath(opts);
|
|
10818
11826
|
// schtasks /tr 不接受多余内嵌引号:路径无空格不加引号,含空格/引号用 \" 转义(0.5.2 修复)
|
|
10819
11827
|
const q = (p) => /[\s"]/.test(p) ? '\\"' + p.replace(/"/g, '\\"') + '\\"' : p;
|
|
10820
11828
|
const prefix = lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1" && ' : '';
|
|
10821
|
-
return prefix + q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --host ' + host + ' --port ' + port;
|
|
11829
|
+
return prefix + q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --profile ' + q(profilePath) + ' --host ' + host + ' --port ' + port;
|
|
10822
11830
|
}
|
|
10823
11831
|
|
|
10824
11832
|
function lanServeArgs(opts) {
|
|
10825
|
-
return ['serve', '--host', String(opts.host || '0.0.0.0'), '--port', String(opts.port || 8787)];
|
|
11833
|
+
return ['serve', '--profile', lanServeProfilePath(opts), '--host', String(opts.host || '0.0.0.0'), '--port', String(opts.port || 8787)];
|
|
10826
11834
|
}
|
|
10827
11835
|
// generic spawn wrapper: YOTTA_LAN_*_BIN may point to a .js stub (testing/advanced use;
|
|
10828
11836
|
// on Windows run it with the current node interpreter automatically)
|
|
@@ -10893,6 +11901,7 @@ function lanAutostartCmdContent(opts) {
|
|
|
10893
11901
|
opts = opts || {};
|
|
10894
11902
|
const host = opts.host || '0.0.0.0';
|
|
10895
11903
|
const port = opts.port || 8787;
|
|
11904
|
+
const profilePath = lanServeProfilePath(opts);
|
|
10896
11905
|
const q = (p) => '"' + String(p).replace(/"/g, '""') + '"';
|
|
10897
11906
|
// 生成文件用纯 ASCII(避免 cmd/VBS 按系统代码页读取中文注释乱码;VBS 自愈内联同一内容)
|
|
10898
11907
|
// v0.22.5: Electron 宿主下需先设 ELECTRON_RUN_AS_NODE=1,否则拉起桌面程序本体而非 Node。
|
|
@@ -10900,7 +11909,7 @@ function lanAutostartCmdContent(opts) {
|
|
|
10900
11909
|
+ 'chcp 65001 >nul\r\n'
|
|
10901
11910
|
+ 'rem ' + LAN_GEN_MARKER + '; remove with: yotta-memory lan disable (English only, keep ASCII)\r\n'
|
|
10902
11911
|
+ (lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1"\r\n' : '')
|
|
10903
|
-
+ q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --host ' + host + ' --port ' + port + ' >> ' + q(lanLogPath(opts)) + ' 2>&1\r\n';
|
|
11912
|
+
+ q(process.execPath) + ' ' + q(runtimeManagedScript()) + ' serve --profile ' + q(profilePath) + ' --host ' + host + ' --port ' + port + ' >> ' + q(lanLogPath(opts)) + ' 2>&1\r\n';
|
|
10904
11913
|
}
|
|
10905
11914
|
function lanVbsContent(opts) {
|
|
10906
11915
|
// v0.6.3 自愈:VBS 内联 autostart.cmd 内容,启动时若 .cmd 缺失/被清理即就地重建,
|
|
@@ -11013,6 +12022,7 @@ function lanWindowsTaskXml(opts) {
|
|
|
11013
12022
|
const nodePath = opts.nodePath || process.execPath;
|
|
11014
12023
|
const scriptPath = opts.scriptPath || runtimeManagedScript();
|
|
11015
12024
|
const logPath = opts.logPath || lanLogPath(opts);
|
|
12025
|
+
const profilePath = lanServeProfilePath(opts);
|
|
11016
12026
|
const userId = opts.userId || '';
|
|
11017
12027
|
const cmdPath = opts.cmdPath || process.env.ComSpec || 'cmd.exe';
|
|
11018
12028
|
// cmd /c 外层引号:命令首段带引号时必须用 ""..." 包裹(cmd 的 /c 引号规则),
|
|
@@ -11022,7 +12032,7 @@ function lanWindowsTaskXml(opts) {
|
|
|
11022
12032
|
// v0.22.5: Electron 宿主(YottaCode shim)下先 set ELECTRON_RUN_AS_NODE=1,
|
|
11023
12033
|
// 否则任务会拉起桌面程序本体(GUI)而不是 Node 服务。
|
|
11024
12034
|
const envPrefix = lanElectronHost(opts) ? 'set "ELECTRON_RUN_AS_NODE=1" && ' : '';
|
|
11025
|
-
const inner = envPrefix + '"' + xmlEscape(nodePath) + '" "' + xmlEscape(scriptPath) + '" serve --host ' + host + ' --port ' + port
|
|
12035
|
+
const inner = envPrefix + '"' + xmlEscape(nodePath) + '" "' + xmlEscape(scriptPath) + '" serve --profile "' + xmlEscape(profilePath) + '" --host ' + host + ' --port ' + port
|
|
11026
12036
|
+ ' >> "' + xmlEscape(logPath) + '" 2>&1';
|
|
11027
12037
|
const triggerXml = onstart
|
|
11028
12038
|
? '<BootTrigger><Enabled>true</Enabled></BootTrigger>'
|
|
@@ -11057,6 +12067,8 @@ function lanWindowsTaskXml(opts) {
|
|
|
11057
12067
|
}
|
|
11058
12068
|
function lanWindowsEnableCore(opts) {
|
|
11059
12069
|
opts = opts || {};
|
|
12070
|
+
const profileResult = lanWriteServeProfile(opts);
|
|
12071
|
+
if (profileResult.error) return { error: true, phase: 'profile', text: profileResult.text };
|
|
11060
12072
|
const trigger = opts.onstart ? 'onstart' : 'onlogon';
|
|
11061
12073
|
const host = opts.host || '0.0.0.0';
|
|
11062
12074
|
const port = opts.port || 8787;
|
|
@@ -11064,7 +12076,7 @@ function lanWindowsEnableCore(opts) {
|
|
|
11064
12076
|
const scriptPath = opts.scriptPath || runtimeManagedScript();
|
|
11065
12077
|
const logPath = lanLogPath(opts);
|
|
11066
12078
|
const userId = lanWindowsTaskUser(opts);
|
|
11067
|
-
const xml = lanWindowsTaskXml({ trigger: trigger, host: host, port: port, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath, userId: userId, electronHost: opts.electronHost });
|
|
12079
|
+
const xml = lanWindowsTaskXml({ trigger: trigger, host: host, port: port, nodePath: nodePath, scriptPath: scriptPath, logPath: logPath, profilePath: profileResult.profilePath, userId: userId, electronHost: opts.electronHost });
|
|
11068
12080
|
const xmlDir = opts.xmlDir || process.env.YOTTA_LAN_TASK_XML_DIR || os.tmpdir();
|
|
11069
12081
|
const xmlPath = opts.xmlPath || path.join(xmlDir, LAN_TASK_NAME + '-task-' + process.pid + '-' + Date.now() + '.xml');
|
|
11070
12082
|
let failure = null;
|
|
@@ -11082,7 +12094,7 @@ function lanWindowsEnableCore(opts) {
|
|
|
11082
12094
|
if (!failure) {
|
|
11083
12095
|
let removedStartup = [];
|
|
11084
12096
|
try { removedStartup = lanRemoveStartupFiles(opts); } catch (e) { removedStartup = []; }
|
|
11085
|
-
return { error: false, mode: 'task', taskName: LAN_TASK_NAME, trigger: trigger, logPath: logPath, removedStartup: removedStartup };
|
|
12097
|
+
return { error: false, mode: 'task', taskName: LAN_TASK_NAME, trigger: trigger, logPath: logPath, profilePath: profileResult.profilePath, removedStartup: removedStartup };
|
|
11086
12098
|
}
|
|
11087
12099
|
try {
|
|
11088
12100
|
lanInstallStartup(opts);
|
|
@@ -11098,6 +12110,7 @@ function lanWindowsEnableCore(opts) {
|
|
|
11098
12110
|
vbsPath: lanVbsPath(opts),
|
|
11099
12111
|
cmdPath: lanAutostartCmdPath(opts),
|
|
11100
12112
|
logPath: logPath,
|
|
12113
|
+
profilePath: profileResult.profilePath,
|
|
11101
12114
|
};
|
|
11102
12115
|
}
|
|
11103
12116
|
|
|
@@ -11112,7 +12125,9 @@ function lanLinuxUnitPath() { return path.join(lanLinuxSystemdUserDir(), LAN_UNI
|
|
|
11112
12125
|
function lanLinuxSystemctlBin() { return process.env.YOTTA_LAN_SYSTEMCTL_BIN || 'systemctl'; }
|
|
11113
12126
|
function lanLinuxLoginctlBin() { return process.env.YOTTA_LAN_LOGINCTL_BIN || 'loginctl'; }
|
|
11114
12127
|
function lanLinuxExecStart(opts) {
|
|
11115
|
-
return [systemdEscapeArg(process.execPath), systemdEscapeArg(runtimeManagedScript())]
|
|
12128
|
+
return [systemdEscapeArg(process.execPath), systemdEscapeArg(runtimeManagedScript())]
|
|
12129
|
+
.concat(lanServeArgs(opts).map(systemdEscapeArg))
|
|
12130
|
+
.join(' ');
|
|
11116
12131
|
}
|
|
11117
12132
|
function lanLinuxUnitContent(opts) {
|
|
11118
12133
|
// keep comments ASCII (avoid locale/encoding issues); ExecStart uses systemd's own quote rules
|
|
@@ -11142,7 +12157,7 @@ function lanCrontabLine(opts) {
|
|
|
11142
12157
|
const parts = ['@reboot'];
|
|
11143
12158
|
if (lanElectronHost(opts)) parts.push('ELECTRON_RUN_AS_NODE=1');
|
|
11144
12159
|
parts.push(shQuote(process.execPath), shQuote(runtimeManagedScript()));
|
|
11145
|
-
return parts.concat(lanServeArgs(opts)).join(' ') + ' >> ' + shQuote(lanLogPath(opts)) + ' 2>&1 ' + LAN_CRONTAB_MARKER;
|
|
12160
|
+
return parts.concat(lanServeArgs(opts).map(shQuote)).join(' ') + ' >> ' + shQuote(lanLogPath(opts)) + ' 2>&1 ' + LAN_CRONTAB_MARKER;
|
|
11146
12161
|
}
|
|
11147
12162
|
function lanCrontabRead() {
|
|
11148
12163
|
try {
|
|
@@ -11221,6 +12236,12 @@ function lanLinuxRemoveCrontab() {
|
|
|
11221
12236
|
return true;
|
|
11222
12237
|
}
|
|
11223
12238
|
function cmdLanLinuxEnable(opts) {
|
|
12239
|
+
const profileResult = lanWriteServeProfile(opts);
|
|
12240
|
+
if (profileResult.error) {
|
|
12241
|
+
console.error('服务 profile 写入失败,拒绝注册开机自启: ' + profileResult.text);
|
|
12242
|
+
process.exit(1);
|
|
12243
|
+
}
|
|
12244
|
+
opts = Object.assign({}, opts, { profilePath: profileResult.profilePath });
|
|
11224
12245
|
if (lanLinuxHasSystemd()) {
|
|
11225
12246
|
try {
|
|
11226
12247
|
const unit = lanLinuxInstallSystemd(opts);
|
|
@@ -11263,6 +12284,23 @@ function cmdLanLinuxDisable() {
|
|
|
11263
12284
|
}
|
|
11264
12285
|
function cmdLanLinuxStatus() {
|
|
11265
12286
|
let any = false;
|
|
12287
|
+
const profilePath = lanServeProfilePath();
|
|
12288
|
+
if (fs.existsSync(profilePath)) {
|
|
12289
|
+
console.log('服务 profile: ' + profilePath);
|
|
12290
|
+
const loaded = loadServeProfile(profilePath);
|
|
12291
|
+
if (loaded.error) {
|
|
12292
|
+
console.log(' 警告: ' + loaded.text);
|
|
12293
|
+
} else {
|
|
12294
|
+
console.log(' 记忆库: ' + loaded.profile.memoryHome);
|
|
12295
|
+
console.log(' 运行时: ' + loaded.profile.runtimeHome);
|
|
12296
|
+
const drift = serveProfileDrift(loaded.profile);
|
|
12297
|
+
if (drift.length) {
|
|
12298
|
+
console.log(' 警告: 服务 profile 与当前配置漂移(' + drift.join(', ') + '),请重新执行 yotta-memory lan enable');
|
|
12299
|
+
}
|
|
12300
|
+
}
|
|
12301
|
+
} else {
|
|
12302
|
+
console.log('服务 profile: 不存在 ' + profilePath + '(请重新执行 yotta-memory lan enable)');
|
|
12303
|
+
}
|
|
11266
12304
|
const unit = lanLinuxUnitPath();
|
|
11267
12305
|
const unitExists = fs.existsSync(unit) && lanFileHasMarker(unit);
|
|
11268
12306
|
if (unitExists) {
|
|
@@ -11284,7 +12322,13 @@ function cmdLanLinuxStatus() {
|
|
|
11284
12322
|
if (lanCrontabHasOurLine(lines)) {
|
|
11285
12323
|
any = true;
|
|
11286
12324
|
console.log('crontab @reboot: 已启用');
|
|
11287
|
-
for (const l of lines)
|
|
12325
|
+
for (const l of lines) {
|
|
12326
|
+
if (l.indexOf(LAN_CRONTAB_MARKER) === -1) continue;
|
|
12327
|
+
console.log(' ' + l.trim());
|
|
12328
|
+
if (!/--profile(?:\s|$)/.test(l)) {
|
|
12329
|
+
console.log(' 警告: crontab 命令未固定服务 profile;请重新执行 yotta-memory lan enable 修复');
|
|
12330
|
+
}
|
|
12331
|
+
}
|
|
11288
12332
|
} else {
|
|
11289
12333
|
console.log('crontab @reboot: 未启用');
|
|
11290
12334
|
}
|
|
@@ -11297,9 +12341,11 @@ function cmdLanWinEnable(opts) {
|
|
|
11297
12341
|
}
|
|
11298
12342
|
const result = lanWindowsEnableCore(opts);
|
|
11299
12343
|
if (result.mode === 'task') {
|
|
12344
|
+
opts = Object.assign({}, opts, { profilePath: result.profilePath });
|
|
11300
12345
|
const triggerText = result.trigger === 'onstart' ? '开机触发(无需登录)' : '登录触发';
|
|
11301
12346
|
console.log('已注册开机自启: 计划任务 ' + LAN_TASK_NAME + '(' + triggerText + ',后台静默运行,不弹窗口)');
|
|
11302
12347
|
console.log('运行命令: ' + lanTaskRunCmd(opts));
|
|
12348
|
+
console.log('服务 profile: ' + result.profilePath);
|
|
11303
12349
|
console.log('日志: ' + result.logPath);
|
|
11304
12350
|
if (result.removedStartup && result.removedStartup.length) {
|
|
11305
12351
|
console.log('已清理旧 Startup 兜底文件 ' + result.removedStartup.length + ' 个(避免双份自启)');
|
|
@@ -11308,6 +12354,10 @@ function cmdLanWinEnable(opts) {
|
|
|
11308
12354
|
return;
|
|
11309
12355
|
}
|
|
11310
12356
|
if (result.error) {
|
|
12357
|
+
if (result.phase === 'profile') {
|
|
12358
|
+
console.error('服务 profile 写入失败,拒绝注册开机自启: ' + String(result.text || '').split(/\r?\n/)[0]);
|
|
12359
|
+
process.exit(1);
|
|
12360
|
+
}
|
|
11311
12361
|
console.error('计划任务注册失败: ' + String(result.reason || '').split(/\r?\n/)[0]);
|
|
11312
12362
|
console.error('写 Startup 自启失败: ' + String(result.text || '').split(/\r?\n/)[0]);
|
|
11313
12363
|
console.error('请用管理员终端执行 yotta-memory lan enable,或将以下命令手动加入启动项: ' + lanTaskRunCmd(opts));
|
|
@@ -11320,6 +12370,7 @@ function cmdLanWinEnable(opts) {
|
|
|
11320
12370
|
console.log('已启用用户级 Startup 静默自启(无需管理员)');
|
|
11321
12371
|
console.log('启动脚本: ' + result.vbsPath);
|
|
11322
12372
|
console.log('启动命令: ' + result.cmdPath);
|
|
12373
|
+
console.log('服务 profile: ' + result.profilePath);
|
|
11323
12374
|
console.log('日志: ' + result.logPath);
|
|
11324
12375
|
console.log('备注: 服务不会立刻启动,需重新登录后自动启动;如需现在运行请执行 yotta-memory serve');
|
|
11325
12376
|
console.log('提示: 如需改回计划任务(后台静默),请用管理员终端重新执行 yotta-memory lan enable');
|
|
@@ -11356,9 +12407,12 @@ function cmdLanWinStatus() {
|
|
|
11356
12407
|
return;
|
|
11357
12408
|
}
|
|
11358
12409
|
let taskFound = false;
|
|
12410
|
+
const profilePath = lanServeProfilePath();
|
|
11359
12411
|
const q = lanWindowsRunSchtasks({}, ['/query', '/tn', LAN_TASK_NAME, '/fo', 'LIST', '/v']);
|
|
11360
12412
|
if (q.status === 0) {
|
|
11361
12413
|
taskFound = true;
|
|
12414
|
+
const xmlQ = lanWindowsRunSchtasks({}, ['/query', '/tn', LAN_TASK_NAME, '/xml']);
|
|
12415
|
+
const xmlSaysS4U = xmlQ.status === 0 && /<LogonType>\s*S4U\s*<\/LogonType>/i.test(String(xmlQ.stdout || ''));
|
|
11362
12416
|
console.log('计划任务 ' + LAN_TASK_NAME + ': 已启用');
|
|
11363
12417
|
const re = /^(状态|Status|登录模式|Logon Mode|上次运行时间|Last Run Time|上次结果|Last Result|要运行的任务|Task To Run)\s*[:=]\s*(.+)$/;
|
|
11364
12418
|
let logonMode = '';
|
|
@@ -11370,12 +12424,31 @@ function cmdLanWinStatus() {
|
|
|
11370
12424
|
if (/^(登录模式|Logon Mode)$/.test(m[1])) logonMode = m[2];
|
|
11371
12425
|
if (/^(要运行的任务|Task To Run)$/.test(m[1])) taskRun = m[2];
|
|
11372
12426
|
}
|
|
11373
|
-
if (/interactive|交互式/i.test(logonMode)) {
|
|
12427
|
+
if (/interactive|交互式/i.test(logonMode) && !xmlSaysS4U) {
|
|
11374
12428
|
console.log(' 提示: 登录模式为交互式(仅登录时运行,且在登录会话会弹窗口)——这是旧版任务;请重新执行 yotta-memory lan enable 修复为后台静默');
|
|
11375
12429
|
}
|
|
11376
12430
|
if (lanTaskCommandNeedsElectronEnv(taskRun)) {
|
|
11377
12431
|
console.log(' 警告: 任务命令指向 Electron 可执行文件(如 YottaCode.exe)但缺少 ELECTRON_RUN_AS_NODE —— 开机将拉起桌面程序而非服务;请重新执行 yotta-memory lan enable 修复');
|
|
11378
12432
|
}
|
|
12433
|
+
if (taskRun && !/--profile(?:\s|$)/.test(taskRun)) {
|
|
12434
|
+
console.log(' 警告: 任务命令未固定服务 profile,S4U / 无用户环境启动可能落到错误记忆库;请重新执行 yotta-memory lan enable 修复');
|
|
12435
|
+
}
|
|
12436
|
+
if (fs.existsSync(profilePath)) {
|
|
12437
|
+
console.log(' 服务 profile: ' + profilePath);
|
|
12438
|
+
const loaded = loadServeProfile(profilePath);
|
|
12439
|
+
if (loaded.error) {
|
|
12440
|
+
console.log(' 警告: ' + loaded.text);
|
|
12441
|
+
} else {
|
|
12442
|
+
console.log(' 记忆库: ' + loaded.profile.memoryHome);
|
|
12443
|
+
console.log(' 运行时: ' + loaded.profile.runtimeHome);
|
|
12444
|
+
const drift = serveProfileDrift(loaded.profile);
|
|
12445
|
+
if (drift.length) {
|
|
12446
|
+
console.log(' 警告: 服务 profile 与当前配置漂移(' + drift.join(', ') + '),请重新执行 yotta-memory lan enable');
|
|
12447
|
+
}
|
|
12448
|
+
}
|
|
12449
|
+
} else {
|
|
12450
|
+
console.log(' 警告: 服务 profile 不存在: ' + profilePath + ';请重新执行 yotta-memory lan enable');
|
|
12451
|
+
}
|
|
11379
12452
|
} else {
|
|
11380
12453
|
console.log('计划任务 ' + LAN_TASK_NAME + ': 未启用');
|
|
11381
12454
|
}
|
|
@@ -12327,6 +13400,10 @@ async function main() {
|
|
|
12327
13400
|
else if (a === '--rules') opts.rules = true;
|
|
12328
13401
|
else if (a === '--propose') opts.propose = true;
|
|
12329
13402
|
else if (a === '--audit') opts.audit = true;
|
|
13403
|
+
else if (a === '--stats') opts.stats = true;
|
|
13404
|
+
else if (a === '--include-flat') opts.includeFlat = true;
|
|
13405
|
+
else if (a === '--no-policy') opts.noPolicy = true;
|
|
13406
|
+
else if (a === '--policy-candidates') opts.policyCandidates = true;
|
|
12330
13407
|
else if (valueOpts.has(a)) {
|
|
12331
13408
|
const v = args[++i];
|
|
12332
13409
|
if (a === '--type') opts.type = v;
|
|
@@ -12370,6 +13447,7 @@ async function main() {
|
|
|
12370
13447
|
else if (a === '--id') opts.id = v;
|
|
12371
13448
|
else if (a === '--time') opts.time = v;
|
|
12372
13449
|
else if (a === '--tools') opts.toolProfile = v;
|
|
13450
|
+
else if (a === '--profile') opts.profile = v;
|
|
12373
13451
|
else if (a === '--mcp-config') opts.mcpConfigPaths = (opts.mcpConfigPaths || []).concat(v);
|
|
12374
13452
|
else if (a === '--skill-dir') opts.skillDirs = (opts.skillDirs || []).concat(v);
|
|
12375
13453
|
else if (a === '--year') opts.years = (opts.years || []).concat(v);
|
|
@@ -12382,6 +13460,8 @@ async function main() {
|
|
|
12382
13460
|
else if (a === '--template') opts.template = v;
|
|
12383
13461
|
else if (a === '--path') opts.path = v;
|
|
12384
13462
|
else if (a === '--from') opts.from = v;
|
|
13463
|
+
else if (a === '--from-plan') opts.fromPlan = v;
|
|
13464
|
+
else if (a === '--batch') opts.batch = v;
|
|
12385
13465
|
} else if (a.startsWith('--')) {
|
|
12386
13466
|
if (a === '--query') {
|
|
12387
13467
|
console.error('未知选项: --query。recall/search 的关键词是位置参数:yotta-memory recall [关键词]');
|
|
@@ -12410,6 +13490,16 @@ async function main() {
|
|
|
12410
13490
|
console.error('位置参数不足:yotta-memory remember <type> <subject> <statement>');
|
|
12411
13491
|
process.exit(2);
|
|
12412
13492
|
}
|
|
13493
|
+
if (first === 'serve' && opts.profile) {
|
|
13494
|
+
const applied = applyServeProfile(opts.profile);
|
|
13495
|
+
if (applied.error) {
|
|
13496
|
+
console.error('服务 profile 启动失败: ' + applied.text);
|
|
13497
|
+
process.exit(2);
|
|
13498
|
+
}
|
|
13499
|
+
opts.profile = applied.profilePath;
|
|
13500
|
+
if (opts.host === undefined && applied.profile.host) opts.host = applied.profile.host;
|
|
13501
|
+
if (opts.port === undefined && applied.profile.port) opts.port = applied.profile.port;
|
|
13502
|
+
}
|
|
12413
13503
|
const runtimeIdentity = resolveIdentity(opts);
|
|
12414
13504
|
setRuntimeAgent(runtimeIdentity.error ? '' : runtimeIdentity.id, runtimeIdentity.error ? '' : runtimeIdentity.agentKey);
|
|
12415
13505
|
switch (first) {
|
|
@@ -12458,6 +13548,8 @@ async function main() {
|
|
|
12458
13548
|
break;
|
|
12459
13549
|
}
|
|
12460
13550
|
case 'reindex': cmdReindex(); break;
|
|
13551
|
+
case 'index': cmdIndex(opts); break;
|
|
13552
|
+
case 'layout': cmdLayout(rest[0], opts); break;
|
|
12461
13553
|
case 'doctor': cmdDoctor(opts); break;
|
|
12462
13554
|
case 'profile': cmdProfile(opts); break;
|
|
12463
13555
|
case 'context': cmdContext(opts); break;
|
|
@@ -12565,6 +13657,9 @@ module.exports = {
|
|
|
12565
13657
|
viewEntriesCore: viewEntriesCore,
|
|
12566
13658
|
viewOverviewCore: viewOverviewCore,
|
|
12567
13659
|
migrateLayout: migrateLayout,
|
|
13660
|
+
layoutPlanCore: layoutPlanCore,
|
|
13661
|
+
layoutApplyCore: layoutApplyCore,
|
|
13662
|
+
layoutRollbackCore: layoutRollbackCore,
|
|
12568
13663
|
loadIndexFor: loadIndexFor,
|
|
12569
13664
|
indexEntriesFor: indexEntriesFor,
|
|
12570
13665
|
loadOwnerIndex: loadOwnerIndex,
|
|
@@ -12575,6 +13670,10 @@ module.exports = {
|
|
|
12575
13670
|
baselineCore: baselineCore,
|
|
12576
13671
|
loadBaselineTemplate: loadBaselineTemplate,
|
|
12577
13672
|
scanCore: scanCore,
|
|
13673
|
+
scanPolicyCandidatesCore: scanPolicyCandidatesCore,
|
|
13674
|
+
loadScanPolicy: loadScanPolicy,
|
|
13675
|
+
scanLineHash: scanLineHash,
|
|
13676
|
+
SCAN_POLICY_FILE: SCAN_POLICY_FILE,
|
|
12578
13677
|
scanQuarantineCore: scanQuarantineCore,
|
|
12579
13678
|
scanRestoreCore: scanRestoreCore,
|
|
12580
13679
|
promptYesNo: promptYesNo,
|
|
@@ -12617,6 +13716,12 @@ module.exports = {
|
|
|
12617
13716
|
runtimeDoctorCore: runtimeDoctorCore,
|
|
12618
13717
|
runtimeEnsureForManagedTask: runtimeEnsureForManagedTask,
|
|
12619
13718
|
runtimeRestartManagedServer: runtimeRestartManagedServer,
|
|
13719
|
+
lanServeProfilePath: lanServeProfilePath,
|
|
13720
|
+
resolveProfilePath: resolveProfilePath,
|
|
13721
|
+
lanWriteServeProfile: lanWriteServeProfile,
|
|
13722
|
+
loadServeProfile: loadServeProfile,
|
|
13723
|
+
applyServeProfile: applyServeProfile,
|
|
13724
|
+
serveProfileDrift: serveProfileDrift,
|
|
12620
13725
|
lanTaskRunCmd: lanTaskRunCmd,
|
|
12621
13726
|
lanStartupDir: lanStartupDir,
|
|
12622
13727
|
lanAutostartDir: lanAutostartDir,
|
|
@@ -12702,6 +13807,10 @@ module.exports = {
|
|
|
12702
13807
|
|
|
12703
13808
|
loadIndex: loadIndex,
|
|
12704
13809
|
saveIndex: saveIndex,
|
|
13810
|
+
indexStatsCore: indexStatsCore,
|
|
13811
|
+
INDEX_VERSION: INDEX_VERSION,
|
|
13812
|
+
INDEX_SHARD_THRESHOLD: INDEX_SHARD_THRESHOLD,
|
|
13813
|
+
INDEX_COMPRESS_TRIGGER_BYTES: INDEX_COMPRESS_TRIGGER_BYTES,
|
|
12705
13814
|
buildIndex: buildIndex,
|
|
12706
13815
|
getIndex: getIndex,
|
|
12707
13816
|
touchIndex: touchIndex,
|
|
@@ -12736,6 +13845,8 @@ module.exports = {
|
|
|
12736
13845
|
listBackupVolumesCore: listBackupVolumesCore,
|
|
12737
13846
|
backupSetupCore: backupSetupCore,
|
|
12738
13847
|
backupStatusCore: backupStatusCore,
|
|
13848
|
+
selectLatestValidBackup: selectLatestValidBackup,
|
|
13849
|
+
BACKUP_FUTURE_TOLERANCE_MS: BACKUP_FUTURE_TOLERANCE_MS,
|
|
12739
13850
|
backupEnsureDailyCore: backupEnsureDailyCore,
|
|
12740
13851
|
backupHealthCore: backupHealthCore,
|
|
12741
13852
|
doctorCore: doctorCore,
|