dsh-metaphysics 0.62.1 → 0.62.2

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/client.almanac.js +69 -65
  3. package/client.bazi.js +123 -119
  4. package/client.chenggu.js +68 -64
  5. package/client.convert.js +71 -67
  6. package/client.daliuren.js +119 -115
  7. package/client.fengshui.js +68 -64
  8. package/client.hepan.js +123 -119
  9. package/client.js +192 -192
  10. package/client.liuyao.js +122 -118
  11. package/client.mbti.js +69 -65
  12. package/client.name.js +69 -65
  13. package/client.numerology.js +68 -64
  14. package/client.qimen.js +124 -120
  15. package/client.star.js +69 -65
  16. package/client.tarot.js +119 -115
  17. package/client.trend.js +113 -109
  18. package/client.xiaoliuren.js +78 -74
  19. package/client.zeri.js +68 -64
  20. package/client.ziwei.js +123 -119
  21. package/engine/bazi.js +6 -5
  22. package/engine/bool.js +195 -0
  23. package/engine/calendar.js +2 -1
  24. package/engine/chenggu.js +2 -1
  25. package/engine/convert.js +2 -1
  26. package/engine/daliuren.js +2 -1
  27. package/engine/diff.js +5 -3
  28. package/engine/fengshui.js +19 -6
  29. package/engine/geo.js +2 -1
  30. package/engine/hepan.js +4 -3
  31. package/engine/index.js +202 -72
  32. package/engine/liuyao.js +24 -5
  33. package/engine/mbti.js +6 -1
  34. package/engine/meihua.js +21 -7
  35. package/engine/name.js +20 -12
  36. package/engine/random.js +17 -3
  37. package/engine/rectify.js +4 -3
  38. package/engine/tarot.js +22 -8
  39. package/engine/trace.js +2 -1
  40. package/engine/trend.js +7 -3
  41. package/engine/xiaoliuren.js +2 -1
  42. package/engine/ziwei.js +4 -3
  43. package/index.js +367 -42
  44. package/package.json +1 -1
  45. package//345/221/275/347/220/206/346/216/222/347/233/230.html +577 -143
package/engine/index.js CHANGED
@@ -10,6 +10,7 @@ import { buildZiweiHepan } from './ziwei-hepan.js';
10
10
  import { buildQimen, buildSnapshot } from './qimen.js';
11
11
  import { geocodeAddress, resolveAddress } from './geo.js';
12
12
  import { toJDN, ganzhiName, dayGanzhiIndex } from './calendar.js';
13
+ import { bool, canonicalFlags, canonicalNumbers } from './bool.js';
13
14
  import { randomOf, isSeed } from './random.js';
14
15
  import { encodeHandle, decodeHandle, resolveHandle, pruneForeignKeys, ownedKeys } from './handle.js';
15
16
  import { askLink, parseAskLink, formOf, restNote, askPackages } from './bridge.js';
@@ -35,7 +36,7 @@ import { buildFengshui } from './fengshui.js';
35
36
  import { buildName } from './name.js';
36
37
  import { buildTrace, traceMarkdown, SYSTEM_LABEL } from './trace.js';
37
38
  import { fail } from './fail.js';
38
- import { assertCivil } from './validate.js';
39
+ import { assertCivil, MIN_YEAR, MAX_YEAR } from './validate.js';
39
40
  import { parseDateTime, daysInMonth } from './datetime.js';
40
41
 
41
42
  export { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf };
@@ -45,6 +46,25 @@ const pad = (n) => String(n).padStart(2, '0');
45
46
 
46
47
  const GENDERS = ['male', 'female'];
47
48
 
49
+ /**
50
+ * 时刻会改变盘面、且允许只给日期的体系:那时按 00:00 排,必须当场说清。
51
+ * 黄历 / 择日 / 公农历转换只以「日」为输入,缺时刻是正常用法;
52
+ * 六爻取日辰与月建定六神、旬空,不看钟点;梅花数字起卦也不看时间——都在名单外。
53
+ * 称骨与小六壬缺时辰直接报错(时辰不可代填),同样不需要这句提示。
54
+ */
55
+ const HOUR_MATTERS = new Set(['bazi', 'ziwei', 'hepan', 'trend', 'meihua', 'daliuren', 'qimen', 'name']);
56
+ /** 缺时刻时那句提示里要说清的「按什么排」,以及该怎么补(hourUnknown 只对八字/趋势有定义) */
57
+ const NO_TIME_NOTE = {
58
+ bazi: { what: '时柱', advice: '知道大概几点就补上,不知道就传 hourUnknown:true(只排三柱)' },
59
+ ziwei: { what: '命宫与生时星曜', advice: '紫微排不出三柱,必须补上出生时刻' },
60
+ hepan: { what: '两侧时柱', advice: '两侧都要出生时刻,合盘才有意义' },
61
+ trend: { what: '时柱与起运', advice: '知道大概几点就补上,不知道就传 hourUnknown:true(按日估算)' },
62
+ meihua: { what: '起卦时刻', advice: '时间起卦要用真实起念时刻,请补 HH:mm' },
63
+ daliuren: { what: '起课时辰(月将与贵人)', advice: '请补起课时刻 HH:mm' },
64
+ qimen: { what: '起局时辰(旬首与局数)', advice: '请补起局时刻 HH:mm' },
65
+ name: { what: '时柱', advice: '请补出生时刻 HH:mm' },
66
+ };
67
+
48
68
  /**
49
69
  * 性别是必填项:它决定大运/大限的顺逆,缺省按某一方排等于给出一张错盘。
50
70
  * 之前用 `=== 'female' ? 'female' : 'male'` 兜底,静默把未填性别的人按男命排。
@@ -512,14 +532,14 @@ function shadowPerson(person = {}) {
512
532
  if (person.gender) out.gender = String(person.gender).toLowerCase();
513
533
  if (person.longitude !== undefined) out.longitude = num6(person.longitude);
514
534
  else if (person.address) out.address = String(person.address).trim();
515
- if (person.lateZi !== undefined) out.lateZi = person.lateZi !== false;
516
- if (person.trueSolar !== undefined) out.trueSolar = person.trueSolar !== false;
517
- if (person.dst !== undefined) out.dst = person.dst !== false;
535
+ if (person.lateZi !== undefined) out.lateZi = bool(person.lateZi, true);
536
+ if (person.trueSolar !== undefined) out.trueSolar = bool(person.trueSolar, true);
537
+ if (person.dst !== undefined) out.dst = bool(person.dst, true);
518
538
  return out;
519
539
  }
520
540
 
521
541
  function qimenLateMode(args) {
522
- if (args.lateZiHourMode === 'next-day' || args.lateZi === true) return 'next-day';
542
+ if (args.lateZiHourMode === 'next-day' || bool(args.lateZi)) return 'next-day';
523
543
  return 'same-day';
524
544
  }
525
545
 
@@ -531,8 +551,8 @@ function shadowParams(args, result) {
531
551
  civil: input.civil,
532
552
  gender: input.gender,
533
553
  longitude: num6(input.longitude),
534
- lateZi: input.lateZi !== false,
535
- trueSolar: input.trueSolar !== false,
554
+ lateZi: bool(input.lateZi, true),
555
+ trueSolar: bool(input.trueSolar, true),
536
556
  dst: !!(input.dst && input.dst.applied),
537
557
  };
538
558
  }
@@ -542,9 +562,9 @@ function shadowParams(args, result) {
542
562
  gender: input.gender,
543
563
  longitude: num6(input.longitude),
544
564
  leapMonth: input.leapMonth || 'split',
545
- leapAsNext: input.leapAsNext === true,
565
+ leapAsNext: bool(input.leapAsNext),
546
566
  schools: input.schools || ZIWEI_SCHOOL_DEFAULTS,
547
- trueSolar: input.trueSolar !== false,
567
+ trueSolar: bool(input.trueSolar, true),
548
568
  dst: !!(input.dst && input.dst.applied),
549
569
  };
550
570
  }
@@ -630,7 +650,10 @@ function normalizeAsOf(value) {
630
650
  const parts = asOfParts(s);
631
651
  if (!parts) return s; // 非法值留给 checkAsOf 报错,指纹不抢答
632
652
  const p2 = (n) => String(n).padStart(2, '0');
633
- return `${parts.year}-${p2(parts.month)}-${p2(parts.day)}T${p2(parts.hour)}:${p2(parts.minute)}`;
653
+ // 年份也要补到 4 位:asOfParts 的正则只认四位年份,0000 原样拼成 '0-01-01T12:00'
654
+ // 会在 checkAsOf 里被当成格式错误,报出「无法解析 asOf:「0-01-01T12:00」」这种看不懂的话。
655
+ const y4 = String(parts.year).padStart(4, '0');
656
+ return `${y4}-${p2(parts.month)}-${p2(parts.day)}T${p2(parts.hour)}:${p2(parts.minute)}`;
634
657
  }
635
658
 
636
659
  /**
@@ -747,6 +770,14 @@ export function compute(input = {}) {
747
770
  // 称骨句柄里的 hour 会跟到八字盘上,被工具层当成误传报错。
748
771
  args = pruneForeignKeys(args, ownedKeys(input, rev.patch));
749
772
  }
773
+ // 开关归一:排盘读的、句柄记的、指纹比的是同一份真布尔。
774
+ // 少了这一刀,`trueSolar:"false"` 会排出「开真太阳时」的盘而句柄写 false——
775
+ // 同一张盘两个身份,影子盘对拍与深链回放都会错过。
776
+ args = canonicalFlags(args);
777
+ // 数字归一:`count:"3"` 与 `count:3` 是同一张盘,句柄与指纹也只能有一个身份。
778
+ // 放在开关之后、asOf / 经度 / verifyPast 整形之前:后面几处校验读到的是真数字,
779
+ // 「收到「9」」这种字面看着没错的报错也就不会再出现。
780
+ args = canonicalNumbers(args);
750
781
  // asOf 在入口就归一成 canonical 串(去首尾空格、只给日期补 12:00):
751
782
  // 排盘、句柄、影子盘指纹读同一份。以前只有指纹 trim,句柄照抄原文,
752
783
  // '2030-01-01' 与 '2030-01-01T12:00' 出的明明是同一张盘,却各得一个 handle、一个 baseId。
@@ -821,6 +852,36 @@ ${r.summary}`;
821
852
  r.summary = `时柱暂缺:起运与大运按日估算,仅供参考
822
853
  ${r.summary}`;
823
854
  }
855
+ // 只给日期、没给时刻:parseDateTime 会把时分补成 00:00,于是照常排出完整四柱/起好局,
856
+ // 而调用方以为「日期就够了」。这类静默默认正是最该说出来的一种:盘面上看不出任何异常。
857
+ // 梅花数字起卦只读 upper/lower,datetime 不参与取卦,不该被这句提示打扰。
858
+ const ignoresClock = r && r.system === 'meihua' && args.mode === 'number';
859
+ const datetimeDateOnly = typeof args.datetime === 'string' && args.datetime.trim() !== ''
860
+ && !/[T\s]\d{1,2}:\d{1,2}/.test(args.datetime.trim());
861
+ // name 还支持 year/month/day(+hour/minute) 分开给生辰:只给年月日时,
862
+ // buildName 内部同样按 00:00 占位,不能因为没走 datetime 就绕过这句提示。
863
+ const nameDateOnly = r && r.system === 'name' && !(typeof args.datetime === 'string' && args.datetime.trim())
864
+ && Number.isInteger(args.year) && Number.isInteger(args.month) && Number.isInteger(args.day)
865
+ && !Number.isInteger(args.hour);
866
+ if (r && !ignoresClock && !r.hourUnknown && !r.timeMissing && HOUR_MATTERS.has(r.system)
867
+ && (datetimeDateOnly || nameDateOnly) && r.summary) {
868
+ r.timeMissing = true;
869
+ const note = NO_TIME_NOTE[r.system] || { what: '时刻', advice: '请补上时刻 HH:mm' };
870
+ r.summary = `⚠ 只给了日期、没有时刻:${note.what}按 00:00 暂算,与真实结果可能整档不同;${note.advice}\n${r.summary}`;
871
+ }
872
+ // 合盘的另一方同样是「只给日期」的静默 00:00,主方有提示、对方没有就等于只报了一半
873
+ if (r && r.system === 'hepan' && r.summary) {
874
+ const missing = [];
875
+ for (const [who, label] of [['partner', '对方'], ['partner2', '第三方']]) {
876
+ const person = args[who];
877
+ const when = person && typeof person === 'object' ? person.datetime : undefined;
878
+ if (typeof when === 'string' && !/[T\s]\d{1,2}:\d{1,2}/.test(when.trim())) missing.push(label);
879
+ }
880
+ if (missing.length) {
881
+ r.timeMissing = true;
882
+ r.summary = `⚠ 只给了日期、没有时刻:${missing.join('、')}的时柱按 00:00 暂算,与真实结果可能整档不同;请补出生时刻 HH:mm\n${r.summary}`;
883
+ }
884
+ }
824
885
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
825
886
  const tier = detailOf(args.detail);
826
887
  if (args.withInterpret) {
@@ -830,13 +891,14 @@ ${r.summary}`;
830
891
  });
831
892
  // 档位只改「回给模型的文本」,不改解析对象:面板拿到的仍是完整的 interpret。
832
893
  // 键名不用 detail / tier:合盘结果里 r.detail 是详批对象、流年行里 r.tier 是档位,都会撞。
833
- if (tier === 'lean') r.interpretTier = 'lean';
894
+ // 有些体系没有解析模块(interpret 为 null),此时不该留下一个没有正文的档位键。
895
+ if (r.interpret && tier !== 'full') r.interpretTier = tier;
834
896
  }
835
897
  // 八字 × 紫微交叉验证:同一份出生信息再排另一套盘,并列两边对同一件事的说法
836
898
  // 交叉验证要再排另一套盘:时辰未知时紫微排不出来,这条支路直接跳过,
837
899
  // 不能拿正午排一张紫微盘来「交叉」,那是两张都不可信的盘互证。
838
- if (args.cross === true && args.hourUnknown === true) r.crossSkipped = 'HOUR_UNKNOWN';
839
- if (args.cross === true && args.hourUnknown !== true && (r.system === 'bazi' || r.system === 'ziwei')) {
900
+ if (bool(args.cross) && bool(args.hourUnknown)) r.crossSkipped = 'HOUR_UNKNOWN';
901
+ if (bool(args.cross) && !bool(args.hourUnknown) && (r.system === 'bazi' || r.system === 'ziwei')) {
840
902
  const cdt = parseDateTime(args.datetime);
841
903
  const common = {
842
904
  ...cdt,
@@ -844,13 +906,28 @@ ${r.summary}`;
844
906
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
845
907
  address: typeof args.address === 'string' ? args.address : undefined,
846
908
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
847
- trueSolar: args.trueSolar !== false,
848
- dst: args.dst !== false,
909
+ trueSolar: bool(args.trueSolar, true),
910
+ dst: bool(args.dst, true),
849
911
  };
850
912
  const asOf = checkAsOf(args.asOf);
913
+ // 八字显式写了 lateZi 时,交叉的紫微盘必须同口径:23:00 后一边按次日、一边按当日,
914
+ // 等于拿两个生日互证。没写时仍按紫微自己的 current-day 缺省,不改既有交叉结果;
915
+ // 显式 lateZishi(紫微历史参数名)优先。
916
+ const crossLateZishi = args.lateZishi !== undefined
917
+ ? args.lateZishi
918
+ : args.lateZi === undefined || args.lateZi === null || args.lateZi === ''
919
+ ? undefined
920
+ : bool(args.lateZi, true) ? 'next-day' : 'current-day';
851
921
  const other = r.system === 'bazi'
852
- ? buildZiwei({ ...common, leapMonth: args.leapMonth, leapAsNext: args.leapAsNext === true, ...ziweiSchoolArgs(args), asOf })
853
- : buildBazi({ ...common, lateZi: args.lateZi !== false, nowYear: asOf ? asOf.year : undefined });
922
+ ? buildZiwei({
923
+ ...common,
924
+ leapMonth: args.leapMonth,
925
+ leapAsNext: bool(args.leapAsNext),
926
+ ...ziweiSchoolArgs(args),
927
+ ...(crossLateZishi !== undefined ? { lateZishi: crossLateZishi } : {}),
928
+ asOf,
929
+ })
930
+ : buildBazi({ ...common, lateZi: bool(args.lateZi, true), nowYear: asOf ? asOf.year : undefined });
854
931
  r.cross = crossCheck(r.system === 'bazi' ? r : other, r.system === 'ziwei' ? r : other);
855
932
  if (r.interpret) {
856
933
  r.interpret.sections.push(crossSection(r.cross));
@@ -864,14 +941,14 @@ ${r.summary}`;
864
941
  ...parseDateTime(args.datetime),
865
942
  // 与主盘同口径:主盘 hourUnknown 时内部按正午占位,过去盘若还用 00:00,
866
943
  // 流年信号就来自子夜的盘,与用户看到的不是同一张
867
- hourUnknown: args.hourUnknown === true,
944
+ hourUnknown: bool(args.hourUnknown),
868
945
  gender: requireGender(args.gender),
869
- lateZi: args.lateZi !== false,
870
- trueSolar: args.trueSolar !== false,
946
+ lateZi: bool(args.lateZi, true),
947
+ trueSolar: bool(args.trueSolar, true),
871
948
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
872
949
  address: typeof args.address === 'string' ? args.address : undefined,
873
950
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
874
- dst: args.dst !== false,
951
+ dst: bool(args.dst, true),
875
952
  nowYear: from,
876
953
  });
877
954
  r.past = {
@@ -881,16 +958,16 @@ ${r.summary}`;
881
958
  };
882
959
  }
883
960
  // 生时校正:候选时辰 × 过去事件,按固定规则打分(仅八字)
884
- if (args.rectify === true && r.system === 'bazi') {
961
+ if (bool(args.rectify) && r.system === 'bazi') {
885
962
  const base = parseDateTime(args.datetime);
886
963
  r.rectify = rectifyBirth({
887
964
  base: { year: base.year, month: base.month, day: base.day, minute: base.minute },
888
965
  gender: requireGender(args.gender),
889
966
  events: Array.isArray(args.events) ? args.events : [],
890
967
  hours: Array.isArray(args.rectifyHours) ? args.rectifyHours : undefined,
891
- lateZi: args.lateZi !== false,
892
- trueSolar: args.trueSolar !== false,
893
- dst: args.dst !== false,
968
+ lateZi: bool(args.lateZi, true),
969
+ trueSolar: bool(args.trueSolar, true),
970
+ dst: bool(args.dst, true),
894
971
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
895
972
  address: typeof args.address === 'string' ? args.address : undefined,
896
973
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
@@ -899,7 +976,7 @@ ${r.summary}`;
899
976
  // 追问:围绕已算好的盘回答「某一年 / 某方面」的具体问题
900
977
  if (typeof args.ask === 'string' && args.ask.trim()) r.followup = followup(r, args.ask);
901
978
  // 推导链:只读结果、不重算;生成失败不影响盘面,单独留一条错误信息
902
- if (args.trace === true) {
979
+ if (bool(args.trace)) {
903
980
  try {
904
981
  const layers = buildTrace(r, { direction: args.direction, gender: args.gender });
905
982
  if (layers) {
@@ -912,7 +989,7 @@ ${r.summary}`;
912
989
  }
913
990
  }
914
991
  // 影子盘 ID:同一组归一化参数 + 同一版本 → 同一个短 ID;显式要时才挂,避免默认输出变长
915
- if (args.shadow === true) {
992
+ if (bool(args.shadow)) {
916
993
  try {
917
994
  r.shadow = chartFingerprint(args, { result: r, version: args.shadowVersion });
918
995
  } catch (e) {
@@ -930,11 +1007,17 @@ ${r.summary}`;
930
1007
  }
931
1008
 
932
1009
  /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON)
933
- * detail:'full'(默认,与历史输出逐字一致)或 'lean'(只给结论、你问的那条与三张表,
934
- * 长论述与「规则·出处」折成一行指针)。省的是回给模型的文本,不改盘面、不改解析对象本身。 */
1010
+ * detail:'full'(默认全文)/ 'lean'(只给结论、所问方向与三张表,长论述与规则出处折成指针)/
1011
+ * 'brief'(在 lean 上再限表行;工具层在 withInterpret 且未指定档位时用这一档)。
1012
+ * 省的是回给模型的文本,不改盘面、不改解析对象本身。
1013
+ * **不设体积上限**:brief 只做「少给哪些维度、表最多几行」这类结构性收敛,
1014
+ * 超长就让它超长——按字节截断会把结论、依据、边界一起腰斩,省下的体积不值这个代价。 */
935
1015
  export function interpretText(i, detail) {
936
1016
  if (!i) return '';
1017
+ const brief = detail === 'brief';
937
1018
  const lean = detail === 'lean';
1019
+ const compact = brief || lean;
1020
+ const BRIEF_TABLE_ROWS = 8;
938
1021
  const LONG_SECTION = 300;
939
1022
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
940
1023
  // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
@@ -950,12 +1033,12 @@ export function interpretText(i, detail) {
950
1033
  if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
951
1034
  const allDims = i.dimensions || [];
952
1035
  // 问了方向就只给那一条:其余四条在 full 档里,模型要展开随时可以再排一次
953
- const dims = lean && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
1036
+ const dims = compact && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
954
1037
  for (const d of dims) {
955
1038
  const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}${d.confidence ? ` 置信度:${d.confidence}` : ''}` : '';
956
1039
  const plain = d.plain ? `(${d.plain})` : '';
957
1040
  // 知识条目:「为什么这样断」+出处+可信度标注,是结论与依据之外的第三层
958
- const know = lean
1041
+ const know = compact
959
1042
  ? ((d.knowledge || []).length ? `\n 规则 ${d.knowledge.length} 条未展开(detail:"full" 给出原文与出处)` : '')
960
1043
  : (d.knowledge || [])
961
1044
  .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
@@ -963,31 +1046,41 @@ export function interpretText(i, detail) {
963
1046
  out.push(`${d.label}:${plain}${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}${know}`);
964
1047
  }
965
1048
  const secs = i.sections || [];
966
- const longSecs = lean ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
1049
+ const longSecs = compact ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
967
1050
  for (const s of secs) {
968
- if (lean && String(s.text || '').length > LONG_SECTION) continue;
1051
+ if (compact && String(s.text || '').length > LONG_SECTION) continue;
969
1052
  out.push(`${s.label}:${s.text}`);
970
1053
  }
971
1054
  if (longSecs.length) out.push(`(另有 ${longSecs.length} 节长论述未展开:${longSecs.map((s) => s.label).join('、')}。需要原文与出处时传 detail:"full")`);
972
1055
  // 三张表:大运 / 流年 / 触发事件(纯文本对齐,工具输出也能直接读)
973
1056
  for (const t of i.tables || []) {
974
1057
  if (!Array.isArray(t.rows) || !t.rows.length) continue;
1058
+ let rows = t.rows;
975
1059
  out.push(`【${t.title}】`);
976
- out.push([t.columns.join(' | '), ...t.rows.map((r) => r.join(' | '))].join('\n'));
1060
+ if (brief && rows.length > BRIEF_TABLE_ROWS) {
1061
+ rows = rows.slice(0, BRIEF_TABLE_ROWS);
1062
+ out.push(`(共 ${t.rows.length} 行,仅列前 ${BRIEF_TABLE_ROWS} 行;全表传 detail:"lean")`);
1063
+ }
1064
+ out.push([t.columns.join(' | '), ...rows.map((r) => r.join(' | '))].join('\n'));
977
1065
  }
978
1066
  if (Array.isArray(i.followups) && i.followups.length) {
979
1067
  out.push('还可以这样追问:' + i.followups.map((q) => `「${q}」`).join(' '));
980
1068
  }
981
- if (Array.isArray(i.glossary) && i.glossary.length) {
1069
+ if (!brief && Array.isArray(i.glossary) && i.glossary.length) {
982
1070
  out.push('名词解释:' + i.glossary.map((g) => `${g.term}=${g.plain}`).join(' '));
983
1071
  }
984
1072
  // 能力边界:这段是给调用方(模型)看的上下文,不是给用户直接念的。
985
1073
  // 写全比只写标题有用——模型知道「为什么不答」,才不会自己补一个答案出来。
1074
+ let text = '';
986
1075
  if (Array.isArray(i.limits) && i.limits.length) {
987
- out.push('【本插件不回答什么】' + i.limits.map((l) => `${l.title}——${l.text}`).join('\n'));
988
- }
989
- if (i.disclaimer) out.push(i.disclaimer);
990
- return out.join('\n');
1076
+ text += `【本插件不回答什么】${i.limits.map((l) => `${l.title}——${l.text}`).join('\n')}\n`;
1077
+ }
1078
+ if (i.disclaimer) text += i.disclaimer;
1079
+ // 不按体积截断:这里只把「边界与免责声明」放在末尾,
1080
+ // 正文一个字都不砍——brief 已经用「少给维度、表限行数」控过长度了,
1081
+ // 再按字节腰斩只会把结论、依据和必要交代一起截掉,反而让模型读到半句话。
1082
+ const head = out.join('\n');
1083
+ return text ? `${head}\n${text}` : head;
991
1084
  }
992
1085
 
993
1086
  /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
@@ -1107,12 +1200,12 @@ export function normalizeLongitudes(args) {
1107
1200
  return out;
1108
1201
  }
1109
1202
 
1110
- /** 输出档位:缺省与 'full' 都是历史全文;'lean' 只回结论与你问的那一条,省模型上下文。
1111
- * 其它值以前会被当成「缺省」静默放过,写了 detail:'brief' 的人以为自己省了钱,其实没有。 */
1203
+ /** 输出档位:缺省 'full'(面板与直接调用保持历史全文);'lean' 去掉长论述与规则出处,
1204
+ * 'brief' 在 lean 上再限表行(不设体积上限)。工具层在 withInterpret 且未指定时传 brief。 */
1112
1205
  function detailOf(value) {
1113
- if (value === undefined || value === null || value === '' || value === 'full') return 'full';
1114
- if (value === 'lean') return 'lean';
1115
- throw fail('E_INPUT', `detail 只能是 'full' 或 'lean':收到「${String(value).slice(0, 24)}」`);
1206
+ if (value === undefined || value === null || value === '') return 'full';
1207
+ if (value === 'full' || value === 'lean' || value === 'brief') return value;
1208
+ throw fail('E_INPUT', `detail 只能是 'full' / 'lean' / 'brief':收到「${String(value).slice(0, 24)}」`);
1116
1209
  }
1117
1210
 
1118
1211
  /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
@@ -1123,6 +1216,11 @@ function checkAsOf(asOf) {
1123
1216
  const parts = asOfParts(asOf);
1124
1217
  if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
1125
1218
  const { year, month, day, hour, minute } = parts;
1219
+ // 与 parseDateTime / assertCivil 同一把年份尺:asOf 只校验月日时,1500 / 2500 / 9999
1220
+ // 会一路排进紫微流月流日,农历表却只覆盖 1600–2400,出来的是一张外推盘。
1221
+ if (year < MIN_YEAR || year > MAX_YEAR) {
1222
+ throw fail('E_ASOF', `asOf 的年份超出可计算范围(${MIN_YEAR}–${MAX_YEAR}):${asOf}`);
1223
+ }
1126
1224
  if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
1127
1225
  throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
1128
1226
  }
@@ -1152,6 +1250,17 @@ function computeRaw(args = {}) {
1152
1250
  throw fail('E_INPUT', `${system} 的 mode 只能是 ${allowed.join(' / ')},收到「${String(args.mode).slice(0, 24)}」`);
1153
1251
  }
1154
1252
  }
1253
+ // gender / direction 的枚举写在 schema 里,引擎直调也得守同一份契约:
1254
+ // 六爻曾把「女」静默当 male 出用神,direction 拼错则静默按 general——
1255
+ // 换一个用神、换一套断法,盘面却看不出任何异常。
1256
+ if (args.gender !== undefined && args.gender !== null && args.gender !== ''
1257
+ && args.gender !== 'male' && args.gender !== 'female') {
1258
+ throw fail('E_INPUT', `gender 只能是 male / female,收到「${String(args.gender).slice(0, 24)}」`);
1259
+ }
1260
+ if (args.direction !== undefined && args.direction !== null && args.direction !== ''
1261
+ && !['general', 'wealth', 'career', 'love', 'health', 'study', 'travel'].includes(args.direction)) {
1262
+ throw fail('E_INPUT', `direction 只能是 general / wealth / career / love / health / study / travel,收到「${String(args.direction).slice(0, 24)}」`);
1263
+ }
1155
1264
  // 风水 fortuneYear 以前只认整数:2026.5 被静默当成「没给」,整块年飞星消失。
1156
1265
  if (args.fortuneYear !== undefined && args.fortuneYear !== null && args.fortuneYear !== '') {
1157
1266
  const n = Number(args.fortuneYear);
@@ -1169,25 +1278,25 @@ function computeRaw(args = {}) {
1169
1278
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1170
1279
  address: typeof args.address === 'string' ? args.address : undefined,
1171
1280
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
1172
- lateZi: args.lateZi !== false,
1173
- trueSolar: args.trueSolar !== false,
1174
- dst: args.dst !== false,
1281
+ lateZi: bool(args.lateZi, true),
1282
+ trueSolar: bool(args.trueSolar, true),
1283
+ dst: bool(args.dst, true),
1175
1284
  // 流年表默认从「今年」起排;要复现历史结论就把基准年钉死
1176
1285
  nowYear: asOf ? asOf.year : undefined,
1177
1286
  // 时辰未知:三柱模式(时柱不排、精度降到日、摘要首行固定提示)
1178
- hourUnknown: args.hourUnknown === true,
1287
+ hourUnknown: bool(args.hourUnknown),
1179
1288
  });
1180
1289
  return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: baziText(result) };
1181
1290
  }
1182
1291
  if (system === 'ziwei') {
1183
1292
  // 命宫由「生月 + 生时支」定:时支未知则整盘无处落脚,没有「大概的紫微盘」这种东西
1184
- if (args.hourUnknown === true) {
1293
+ if (bool(args.hourUnknown)) {
1185
1294
  throw fail('HOUR_UNKNOWN', '紫微必须有出生时刻:命宫由生月与生时支定,时支未知则整盘不成立。请补上出生时刻,或改用八字三柱模式只看年/月/日。');
1186
1295
  }
1187
1296
  // 与奇门同一个别名口径:lateZi 是八字/六壬的参数名,紫微的历史参数名是 lateZishi。
1188
1297
  // 不归一的话 revise「晚子时算次日」会照常回执已应用,盘却按默认 current-day 排。
1189
1298
  if (args.lateZi !== undefined && args.lateZishi === undefined) {
1190
- args = { ...args, lateZishi: args.lateZi === false ? 'current-day' : 'next-day' };
1299
+ args = { ...args, lateZishi: bool(args.lateZi, true) ? 'next-day' : 'current-day' };
1191
1300
  }
1192
1301
  const dt = parseDateTime(args.datetime);
1193
1302
  const asOf = checkAsOf(args.asOf);
@@ -1195,13 +1304,13 @@ function computeRaw(args = {}) {
1195
1304
  ...dt,
1196
1305
  gender: requireGender(args.gender),
1197
1306
  leapMonth: args.leapMonth,
1198
- leapAsNext: args.leapAsNext === true,
1307
+ leapAsNext: bool(args.leapAsNext),
1199
1308
  ...ziweiSchoolArgs(args),
1200
1309
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1201
1310
  address: typeof args.address === 'string' ? args.address : undefined,
1202
1311
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
1203
- trueSolar: args.trueSolar,
1204
- dst: args.dst !== false,
1312
+ trueSolar: bool(args.trueSolar, true),
1313
+ dst: bool(args.dst, true),
1205
1314
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
1206
1315
  asOf,
1207
1316
  });
@@ -1221,7 +1330,7 @@ function computeRaw(args = {}) {
1221
1330
  }
1222
1331
  if (system === 'daliuren') {
1223
1332
  const dt = parseDateTime(args.datetime);
1224
- const lateZi = args.lateZi !== false;
1333
+ const lateZi = bool(args.lateZi, true);
1225
1334
  const result = buildDaliuren({ ...dt, lateZi });
1226
1335
  result.lateZi = lateZi;
1227
1336
  return { ...result, precision: dt.precision, summary: daliurenText(result) };
@@ -1235,7 +1344,7 @@ function computeRaw(args = {}) {
1235
1344
  // lateZiHourMode 是奇门的历史参数名,lateZi 是八字/六壬的;两者互为别名。
1236
1345
  const mode = args.lateZiHourMode !== undefined
1237
1346
  ? args.lateZiHourMode
1238
- : args.lateZi === true ? 'next-day' : args.lateZi === false ? 'same-day' : undefined;
1347
+ : args.lateZi === undefined || args.lateZi === null ? undefined : bool(args.lateZi) ? 'next-day' : 'same-day';
1239
1348
  if (mode !== undefined && !LATE_ZI_MODES.includes(mode)) {
1240
1349
  throw fail('E_INPUT', `lateZiHourMode 只能是 same-day / next-day,收到「${mode}」`);
1241
1350
  }
@@ -1245,9 +1354,14 @@ function computeRaw(args = {}) {
1245
1354
  return { ...result, summary: qimenText(result) };
1246
1355
  }
1247
1356
  if (system === 'hepan') {
1248
- // 双方夫妻宫、大运都依赖时支:任一方时辰未知就不出盘,免得「差不多」的合盘误导决定
1249
- if (args.hourUnknown === true || (args.partner && args.partner.hourUnknown === true)) {
1250
- throw fail('HOUR_UNKNOWN', '合盘需要双方都有出生时刻:夫妻宫与大运都依赖时支。请补齐时刻后再看,或用八字三柱模式各自看年/月/日。');
1357
+ // 双方(以及可选的第三方)夫妻宫、大运都依赖时支:任一方时辰未知就不出盘,
1358
+ // 免得「差不多」的合盘误导决定。第三方只在给了 partner2 时才参与校验,
1359
+ // 但一旦参与,口径必须和主方、对方一致——不能主方缺时柱就拦、第三方缺时柱却照排。
1360
+ const unknownSide = bool(args.hourUnknown)
1361
+ || (args.partner && bool(args.partner.hourUnknown))
1362
+ || (args.partner2 && bool(args.partner2.hourUnknown));
1363
+ if (unknownSide) {
1364
+ throw fail('HOUR_UNKNOWN', '合盘需要每一方都有出生时刻:夫妻宫与大运都依赖时支。请补齐时刻后再看,或用八字三柱模式各自看年/月/日。');
1251
1365
  }
1252
1366
  const partner = args.partner;
1253
1367
  if (!partner || typeof partner !== 'object') {
@@ -1269,12 +1383,23 @@ function computeRaw(args = {}) {
1269
1383
  // 性别决定双方大运/大限顺逆,两边都不能用默认值
1270
1384
  const sideOf = (src, who) => {
1271
1385
  const dt = parseDateTime(src.datetime);
1386
+ // 紫微读的是历史参数名 lateZishi,合盘这一层若不转写,`chart:'ziwei', lateZi:false`
1387
+ // 会排出与 true 完全相同的盘(八字合盘同一开关却生效)。
1388
+ const lateZishi = src.lateZishi !== undefined
1389
+ ? src.lateZishi
1390
+ : src.lateZi === undefined || src.lateZi === null || src.lateZi === ''
1391
+ ? undefined
1392
+ : bool(src.lateZi, true) ? 'next-day' : 'current-day';
1272
1393
  return {
1273
1394
  ...dt,
1274
1395
  gender: requireGender(src.gender, who),
1275
1396
  trueSolar: src.trueSolar,
1276
1397
  lateZi: src.lateZi,
1277
- dst: src.dst !== false,
1398
+ ...(lateZishi !== undefined ? { lateZishi } : {}),
1399
+ // 紫微的闰月口径同属「读盘时才算」的参数,漏传会让合盘与单盘在闰月出生时差一个月
1400
+ ...(src.leapMonth !== undefined ? { leapMonth: src.leapMonth } : {}),
1401
+ ...(src.leapAsNext !== undefined ? { leapAsNext: bool(src.leapAsNext) } : {}),
1402
+ dst: bool(src.dst, true),
1278
1403
  address: typeof src.address === 'string' ? src.address : undefined,
1279
1404
  longitude: typeof src.longitude === 'number' ? src.longitude : undefined,
1280
1405
  geo: src.geo && typeof src.geo === 'object' ? src.geo : undefined,
@@ -1428,7 +1553,7 @@ function computeRaw(args = {}) {
1428
1553
  lunarYear: Number(args.lunarYear),
1429
1554
  lunarMonth: Number(args.lunarMonth),
1430
1555
  lunarDay: Number(args.lunarDay),
1431
- isLeapMonth: args.isLeapMonth === true,
1556
+ isLeapMonth: bool(args.isLeapMonth),
1432
1557
  });
1433
1558
  } else {
1434
1559
  const dt = parseDateTime(args.datetime);
@@ -1479,18 +1604,18 @@ function computeRaw(args = {}) {
1479
1604
  nowYear: nowParts ? nowParts.year : undefined,
1480
1605
  gender: requireGender(args.gender),
1481
1606
  toAge: args.toAge,
1482
- lateZi: args.lateZi !== false,
1483
- trueSolar: args.trueSolar !== false,
1484
- dst: args.dst !== false,
1607
+ lateZi: bool(args.lateZi, true),
1608
+ trueSolar: bool(args.trueSolar, true),
1609
+ dst: bool(args.dst, true),
1485
1610
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1486
1611
  address: typeof args.address === 'string' ? args.address : undefined,
1487
1612
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
1488
- hourUnknown: args.hourUnknown === true,
1613
+ hourUnknown: bool(args.hourUnknown),
1489
1614
  });
1490
1615
  // 趋势没有独立的 dayun 字段(大运在 buildTrend 内部用),所以精度标记挂在结果顶层
1491
1616
  return {
1492
1617
  ...result,
1493
- ...(args.hourUnknown === true ? { hourUnknown: true, hourPrecision: 'day' } : {}),
1618
+ ...(bool(args.hourUnknown) ? { hourUnknown: true, hourPrecision: 'day' } : {}),
1494
1619
  precision: dt.precision,
1495
1620
  summary: trendText(result),
1496
1621
  };
@@ -1513,7 +1638,7 @@ function computeRaw(args = {}) {
1513
1638
  ...(typeof args.facingDegree === 'number' ? { facingDegree: args.facingDegree } : {}),
1514
1639
  ...(typeof args.fortuneDate === 'string' ? { fortuneDate: args.fortuneDate } : {}),
1515
1640
  ...(typeof args.fortuneTime === 'string' ? { fortuneTime: args.fortuneTime } : {}),
1516
- dst: args.dst !== false,
1641
+ dst: bool(args.dst, true),
1517
1642
  });
1518
1643
  return { ...result, summary: fengshuiText(result) };
1519
1644
  }
@@ -1524,7 +1649,7 @@ function computeRaw(args = {}) {
1524
1649
  facing: args.facing,
1525
1650
  ...(args.datetime ? parseDateTime(args.datetime) : {}),
1526
1651
  ...(args.gender === 'male' || args.gender === 'female' ? { gender: args.gender } : {}),
1527
- dst: args.dst !== false,
1652
+ dst: bool(args.dst, true),
1528
1653
  });
1529
1654
  return { ...result, summary: fengshuiText(result) };
1530
1655
  }
@@ -1534,7 +1659,7 @@ function computeRaw(args = {}) {
1534
1659
  year: dt.year, month: dt.month, day: dt.day,
1535
1660
  gender: requireGender(args.gender),
1536
1661
  fortuneYear: args.fortuneYear,
1537
- dst: args.dst !== false,
1662
+ dst: bool(args.dst, true),
1538
1663
  });
1539
1664
  return { ...result, summary: fengshuiText(result) };
1540
1665
  }
@@ -1563,9 +1688,9 @@ function computeRaw(args = {}) {
1563
1688
  ...birth,
1564
1689
  ...(args.gender === 'male' || args.gender === 'female' ? { gender: args.gender } : {}),
1565
1690
  ...(args.datetime ? {
1566
- lateZi: args.lateZi !== false,
1567
- trueSolar: args.trueSolar !== false,
1568
- dst: args.dst !== false,
1691
+ lateZi: bool(args.lateZi, true),
1692
+ trueSolar: bool(args.trueSolar, true),
1693
+ dst: bool(args.dst, true),
1569
1694
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1570
1695
  address: typeof args.address === 'string' ? args.address : undefined,
1571
1696
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
@@ -1711,8 +1836,11 @@ function mbtiText(r) {
1711
1836
  const stack = Array.isArray(p.stack) && p.stack.length
1712
1837
  ? p.stack.map((f, i) => `${i === 0 ? '主导 ' : ''}${f.code}`).join(' → ') + `\n ${p.stack.map((f) => `${f.code}:${f.say}`).join('\n ')}`
1713
1838
  : '';
1839
+ const missing = Array.isArray(r.unanswered) && r.unanswered.length
1840
+ ? ` 未答维度:${r.unanswered.join('、')}(该维按平手占位)`
1841
+ : '';
1714
1842
  return [
1715
- `【十六型人格】${r.type}(${p.title})· ${p.keywords} 已答 ${r.answered}/${r.total} 题`,
1843
+ `【十六型人格】${r.type}(${p.title})· ${p.keywords} 已答 ${r.answered}/${r.total} 题${missing}`,
1716
1844
  `一句:${p.oneLine}`,
1717
1845
  '【四维】',
1718
1846
  ...dims,
@@ -1886,6 +2014,8 @@ export function renderText(result) {
1886
2014
  parts.push(`【推导链】${SYSTEM_LABEL[result.system] || result.system} · ${result.trace.length} 层\n`
1887
2015
  + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
1888
2016
  }
2017
+ // 解析段按档位渲染。brief 只做结构性收敛(少给维度、表限行数),不压体积:
2018
+ // 盘面、句柄、解析全都不裁,这段有多长就多长。
1889
2019
  if (result.interpret) {
1890
2020
  const extra = interpretText(result.interpret, result.interpretTier);
1891
2021
  if (extra) parts.push(extra);