@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.
@@ -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.23.3';
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.17.0 规模:新写入按 <yyyy>/<mm> 分层(公共 facts/<yyyy>/<mm>/,私密 private/<owner>/<type>/<yyyy>/<mm>/)。
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
- for (const fp of fs.readdirSync(yearDir)) {
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 = 4;
2291
- // v0.8.1 超大库索引分片:公共 index.json 超过阈值按年份分片(index-<year>.json),避免单文件膨胀。
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
- // 安全校验:分片文件名必须匹配 index-YYYY.json,防 manifest 篡改导致路径穿越。
2294
- const SHARD_RE = /^index-\d{4}\.json$/;
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 indexShardName(year) { return 'index-' + year + '.json'; }
2297
- function shardYear(name) { return String(name).slice('index-'.length, -'.json'.length); }
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
- // v0.8.0 语义索引(字段加权 + 拼音 token)version=3;v0.8.1 分片 version=4;旧版索引返回 null 触发重建
2332
- if (d.version && d.version >= INDEX_VERSION) return d.entries;
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
- // v0.8.1 分片 manifest:串联各分片条目
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
- if (!isSafeShardName(sh)) continue;
2341
- const sp = path.join(root, sh);
2342
- if (!fs.existsSync(sp)) continue;
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 < INDEX_VERSION) return null;
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 < INDEX_VERSION) return null;
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 sp = path.join(root, sh);
2376
- if (!fs.existsSync(sp)) continue;
2377
- const sd = JSON.parse(fs.readFileSync(sp, 'utf8'));
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
- if (clean.length > INDEX_SHARD_THRESHOLD) {
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
- atomicWriteFileSync(path.join(root, name), JSON.stringify({ version: INDEX_VERSION, year: (parseInt(y, 10) || 0), entries: byYear[y] }, null, 2), 'utf8');
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({ version: INDEX_VERSION, updated: today(), shards: newShards, count: clean.length }, null, 2), 'utf8');
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 sp = path.join(root, sh);
2808
- if (!fs.existsSync(sp)) continue;
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) atomicWriteFileSync(sp, JSON.stringify(sd, null, 2), 'utf8');
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 createdTs = latest && latest.created ? new Date(latest.created).getTime() : NaN;
3456
- const ageHours = isNaN(createdTs) ? null : (now - createdTs) / 3600000;
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
- '- 上次成功: ' + (cfg.backup_last_success || (latest && latest.created) || '(无)'),
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(function (a, b) {
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 = latestBackupByCreated(listed.backups);
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.latest) return { level: 'warning', needsSetup: true, text: '自动备份已启用,但还没有成功备份。请检查 backup 目录与调度器。' };
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 level = metrics.some(function (m) { return m.level === 'warning'; })
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$/.test(top)) return true;
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(function (a, b) { return String(b.id).localeCompare(String(a.id)); });
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[0] && listed.backups[0].id);
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 { files: 0, encrypted: 0, mislabelled: 0, scanned: 0, hits: 0, byClass: byClass, bySeverity: bySeverity, maxSeverity: 'safe', truncated: false };
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 + ' 条' + (report.summary.truncated ? '(已达上限,结果被截断)' : ''),
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" &amp;&amp; ' : '';
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>&amp;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())].concat(lanServeArgs(opts)).join(' ');
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) if (l.indexOf(LAN_CRONTAB_MARKER) !== -1) console.log(' ' + l.trim());
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,