dsh-metaphysics 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ // C3 对话桥替代方案:深链 / 复制 / 结构化参数。
2
+ //
3
+ // 背景:面板原本靠会话槽位 conversation.input.right 拿 inputActions,再把提问写进输入框。
4
+ // 那个槽位不稳定(宿主改过名、也可能根本没声明),桥一断用户就没有路把盘送进对话。
5
+ // 这里给三条不依赖宿主的通道,桥能用时仍然优先用桥:
6
+ //
7
+ // 1. 深链:把「排这张盘用的参数」编码进 URL 片段(#mp=<handle>&q=<问题>)。
8
+ // 打开链接的人(换台机器、发给朋友、自己收藏)能还原同一张盘,不必重报出生信息。
9
+ // 2. 复制:两档现成文本——完整提问(含盘面摘要与回答骨架)与一句话精简版。
10
+ // 3. 结构化参数:一段 JSON,助手可以直接拿 args 去调 compute,不必解析自然语言。
11
+ //
12
+ // 约定:
13
+ // * 深链里用的是 C1 句柄(mc1.<body>.<校验位>),所以截断/改字会明确报错,不会静默排成另一张盘;
14
+ // * 链接只带「排盘参数 + 一句问题」,不带摘要、不带解析——摘要可能有好几 KB,URL 装不下;
15
+ // * 这里只做编解码与文案拼装,不排盘、不调用模型,node 与浏览器都能跑(不依赖 Buffer / btoa)。
16
+ import { encodeHandle, decodeHandle, HANDLE_PREFIX } from './handle.js';
17
+ import { askPrompt } from './followup.js';
18
+
19
+ /** 问题进链接前截断的长度:再长就交给「复制完整提问」那一档 */
20
+ export const LINK_MAX_QUESTION = 200;
21
+ /** 精简版里问题的截断长度 */
22
+ const BRIEF_MAX_QUESTION = 60;
23
+
24
+ const SYSTEM_LABEL = {
25
+ bazi: '八字', ziwei: '紫微', liuyao: '六爻', meihua: '梅花', daliuren: '大六壬',
26
+ qimen: '奇门', hepan: '合盘', tarot: '塔罗', almanac: '黄历', zeri: '择日',
27
+ chenggu: '称骨', xiaoliuren: '小六壬', star: '星座', numerology: '生命数字',
28
+ mbti: '人格', trend: '人生K线', fengshui: '风水', name: '起名', convert: '农历',
29
+ };
30
+
31
+ const labelOf = (system) => SYSTEM_LABEL[system] || system || '未知体系';
32
+
33
+ /** 只保留 URL 片段能用的一段:去掉已有 hash,其余原样 */
34
+ function baseOf(base) {
35
+ const s = String(base || '');
36
+ const i = s.indexOf('#');
37
+ return i < 0 ? s : s.slice(0, i);
38
+ }
39
+
40
+ function encodeQuestion(q) {
41
+ try { return encodeURIComponent(q); } catch (e) { return ''; }
42
+ }
43
+
44
+ function decodeQuestion(s) {
45
+ try { return decodeURIComponent(s); } catch (e) { return String(s || ''); }
46
+ }
47
+
48
+ /**
49
+ * 参数 → 深链。没有可编码的参数(或参数里只有输出开关)时返回 ''。
50
+ *
51
+ * askLink({ system: 'bazi', datetime: '1990-05-12T14:30', gender: 'female' }, { question: '2027 年事业', base: url })
52
+ * → '<url>#mp=mc1.xxx.yyy&q=2027%20%E5%B9%B4%E4%BA%8B%E4%B8%9A'
53
+ *
54
+ * 也接受现成句柄:askLink({ handle: r.handle }, { question, base })。
55
+ */
56
+ export function askLink(args = {}, opts = {}) {
57
+ const o = opts || {};
58
+ const raw = String(o.handle || (args && args.handle) || '').trim();
59
+ const handle = raw || encodeHandle(args || {});
60
+ if (!handle || !handle.startsWith(HANDLE_PREFIX)) return '';
61
+ const q = String(o.question || '').trim().slice(0, LINK_MAX_QUESTION);
62
+ const parts = [`mp=${handle}`];
63
+ if (q) parts.push(`q=${encodeQuestion(q)}`);
64
+ return `${baseOf(o.base)}#${parts.join('&')}`;
65
+ }
66
+
67
+ /**
68
+ * 深链 → 参数。任何不合法都不抛异常,返回 { ok:false, code, message },由调用方决定是
69
+ * 静默忽略还是提示一句——分享出去的老链接、被聊天软件截断的链接都会走到这里。
70
+ *
71
+ * 认这几种写法:完整 URL、只有片段(#mp=…)、只有查询串(?mp=…)、裸的 mp=…
72
+ */
73
+ export function parseAskLink(text) {
74
+ const raw = String(text || '').trim();
75
+ if (!raw) return { ok: false, code: 'E_LINK', message: '没有链接内容:请粘贴带 #mp= 的完整链接' };
76
+
77
+ // 片段的优先级高于查询串:链接里同时有时,以片段为准(那是本插件自己写的)
78
+ let body = '';
79
+ const hashAt = raw.indexOf('#');
80
+ if (hashAt >= 0 && raw.slice(hashAt + 1).trim()) body = raw.slice(hashAt + 1).trim();
81
+ else {
82
+ const qAt = raw.indexOf('?');
83
+ if (qAt >= 0 && raw.slice(qAt + 1).trim()) body = raw.slice(qAt + 1).trim();
84
+ else if (/^mp=/.test(raw)) body = raw;
85
+ }
86
+ if (!body) return { ok: false, code: 'E_LINK', message: '链接里没有 #mp= 片段:这不是本插件生成的深链' };
87
+
88
+ const fields = {};
89
+ for (const seg of body.split('&')) {
90
+ if (!seg) continue;
91
+ const eq = seg.indexOf('=');
92
+ if (eq <= 0) continue;
93
+ const k = seg.slice(0, eq);
94
+ const v = seg.slice(eq + 1);
95
+ if (k === 'mp') fields.mp = v;
96
+ else if (k === 'q') fields.q = v;
97
+ }
98
+ if (!fields.mp) return { ok: false, code: 'E_LINK', message: '链接里没有 mp 参数(只有问题没有盘):无法还原盘面' };
99
+
100
+ let args;
101
+ try {
102
+ args = decodeHandle(fields.mp);
103
+ } catch (e) {
104
+ return { ok: false, code: (e && e.code) || 'E_HANDLE', message: (e && e.message) || '句柄无法解析' };
105
+ }
106
+ const question = fields.q ? decodeQuestion(fields.q).slice(0, LINK_MAX_QUESTION) : '';
107
+ return {
108
+ ok: true,
109
+ handle: fields.mp,
110
+ args,
111
+ question,
112
+ system: args.system || '',
113
+ label: labelOf(args.system),
114
+ };
115
+ }
116
+
117
+ const splitDateTime = (dt) => {
118
+ const m = /^(\d{4}-\d{1,2}-\d{1,2})[T ](\d{1,2}:\d{2})/.exec(String(dt || '').trim());
119
+ if (!m) return { date: '', time: '' };
120
+ const [y, mo, d] = m[1].split('-');
121
+ return { date: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, time: m[2].padStart(5, '0') };
122
+ };
123
+
124
+ /**
125
+ * 这些参数能原样填回表单;其余(partner / candidates / answers / mode / upper …)
126
+ * 由调用方提示「未还原」——只报真的填回去的,不假装全还原了。
127
+ */
128
+ const FORM_KEYS = ['gender', 'address', 'trueSolar', 'lateZi', 'lines'];
129
+
130
+ /**
131
+ * 深链 → 表单初值。面板与独立页共用一份,避免两边各自猜字段名。
132
+ * 返回 { tab, date, time, ... , question, restored[], rest{} };rest 是没还原的参数,用于如实提示。
133
+ */
134
+ export function formOf(parsed) {
135
+ const p = parsed && parsed.ok ? parsed : null;
136
+ const args = (p && p.args) || {};
137
+ const dt = splitDateTime(args.datetime);
138
+ const out = {
139
+ tab: args.system || '',
140
+ date: dt.date,
141
+ time: dt.time,
142
+ question: (p && p.question) || '',
143
+ restored: [],
144
+ rest: {},
145
+ };
146
+ if (out.tab) out.restored.push('system');
147
+ if (dt.date) out.restored.push('datetime');
148
+ for (const k of FORM_KEYS) {
149
+ if (args[k] === undefined || args[k] === null) continue;
150
+ out[k] = args[k];
151
+ out.restored.push(k);
152
+ }
153
+ // 问题不是盘面参数,但它跟着链接走,单独记一笔
154
+ if (out.question) out.restored.push('question');
155
+ for (const [k, v] of Object.entries(args)) {
156
+ if (k === 'system' || k === 'datetime' || FORM_KEYS.includes(k)) continue;
157
+ out.rest[k] = v;
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** 未还原参数的提示文案:空对象返回 ''(不打扰) */
163
+ export function restNote(form) {
164
+ const keys = Object.keys((form && form.rest) || {});
165
+ if (!keys.length) return '';
166
+ return `链接里还有 ${keys.length} 项没填回表单(${keys.join('、')}):这些只在引擎调用里生效,页面上按默认值显示。`;
167
+ }
168
+
169
+ /**
170
+ * 一张盘 → 三档复制文本 + 深链。
171
+ *
172
+ * full 完整提问(与桥发出去的是同一份,含盘面摘要、层次与回答骨架)
173
+ * brief 一句话版:体系 + 句柄 + 问题,够助手认出是哪张盘
174
+ * structured 结构化参数(JSON),助手可直接照 args 调 compute
175
+ * link 深链(没有句柄时为空)
176
+ */
177
+ export function askPackages(result, opts = {}) {
178
+ const o = opts || {};
179
+ const r = result || {};
180
+ const system = r.system || '';
181
+ const label = labelOf(system);
182
+ const question = String(o.question || '').trim();
183
+ const handle = String(r.handle || '').trim();
184
+
185
+ let full = '';
186
+ try {
187
+ const p = askPrompt(r, { question, interpret: o.interpret });
188
+ full = (p && p.text) || String(r.summary || '');
189
+ } catch (e) {
190
+ full = String(r.summary || '');
191
+ }
192
+
193
+ const briefQ = question
194
+ ? (question.length > BRIEF_MAX_QUESTION ? `${question.slice(0, BRIEF_MAX_QUESTION)}…` : question)
195
+ : '(未填问题:按摘要讲主线)';
196
+ const brief = handle
197
+ ? `【命理追问】${label}|${handle}|问:${briefQ}`
198
+ : `【命理追问】${label}|问:${briefQ}`;
199
+
200
+ const args = {};
201
+ if (handle) args.handle = handle;
202
+ if (system) args.system = system;
203
+ if (question) args.question = question;
204
+ const structured = JSON.stringify({ tool: 'metaphysics', v: 1, args }, null, 2);
205
+
206
+ const link = handle ? askLink({ handle }, { question, base: o.base }) : '';
207
+
208
+ return { system, label, handle, question, full, brief, structured, link };
209
+ }
210
+
211
+ export default askPackages;
@@ -25,6 +25,11 @@ export const CITATION_BOOKS = {
25
25
  author: '佚名',
26
26
  base: 'https://www.diancang.xyz/xuanxuewushu/qiongtongbaojian/',
27
27
  },
28
+ daliuren: {
29
+ name: '大六壬大全',
30
+ author: '明·郭载騋校(四库全书本)',
31
+ base: 'https://zh.wikisource.org/wiki/六壬大全',
32
+ },
28
33
  qimen: {
29
34
  name: '奇门遁甲统宗',
30
35
  author: '旧题诸葛亮(传)',
@@ -35,6 +40,11 @@ export const CITATION_BOOKS = {
35
40
  author: '宋·邵雍(传)',
36
41
  base: 'https://www.diancang.xyz/xuanxuewushu/meihuayishu/',
37
42
  },
43
+ ditian: {
44
+ name: '滴天髓',
45
+ author: '明·京图(通行本)',
46
+ base: 'https://www.diancang.xyz/xuanxuewushu/ditiansui/',
47
+ },
38
48
  liuyao: {
39
49
  name: '增删卜易',
40
50
  author: '清·野鹤老人',
@@ -437,6 +447,41 @@ export const CITATIONS = [
437
447
  plain: '应期先分动静:静爻在逢值或逢冲之日应,动爻在逢合或逢值之日应;其余按入墓、旬空、月破等逐条推。',
438
448
  url: 'https://zh.wikisource.org/wiki/增刪卜易/26又3',
439
449
  },
450
+ {
451
+ id: 'dl-jigong',
452
+ book: 'daliuren',
453
+ chapter: '卷一·起例(十干寄宫)',
454
+ quote: '甲课寅兮乙课辰,丙戊课巳不须论。丁己课未庚申上,辛戌壬亥是其真。癸课原来丑宫坐,分明不用四正神。',
455
+ plain: '十干寄宫是四课起手:甲寄寅、乙寄辰、丙戊寄巳、丁己寄未、庚寄申、辛寄戌、壬寄亥、癸寄丑;丙戊、丁己各同宫,子午卯酉四个正位不寄。',
456
+ url: 'https://zh.wikisource.org/wiki/六壬大全/1',
457
+ },
458
+ {
459
+ id: 'dl-zeike',
460
+ book: 'daliuren',
461
+ chapter: '卷一·起例(贼克法)',
462
+ quote: '取课先从下贼呼,如无下贼上克初。初传之上名中次,中上加临是末居。三传既定天盘将,此是入式法第一。',
463
+ plain: '九宗门取初传先看下贼上,没有下贼再看上克下;初传定后,中传取初传所乘之神,末传再顺取一位,三传齐了才布天盘天将。',
464
+ url: 'https://zh.wikisource.org/wiki/六壬大全/1',
465
+ },
466
+ {
467
+ id: 'dl-yuejiang',
468
+ book: 'daliuren',
469
+ chapter: '卷二·神将释(月将)',
470
+ quote: '逆布者谓之月将,其实即十二神也。',
471
+ plain: '月将就是十二支神,按太阳过宫排:以登明为首逆布十二宫;起课时把月将加在占时上,顺布成天盘。',
472
+ url: 'https://zh.wikisource.org/wiki/六壬大全/2',
473
+ },
474
+ {
475
+ id: 'dt-yiqi-tonggen',
476
+ book: 'ditian',
477
+ chapter: '干支总论',
478
+ quote: '天全一气,不可使地德莫之载。地全三物,不可使天道莫之容。',
479
+ plain: '四柱天干同属一气,还得地支能承载它;地支所藏三干若不透到天干,也显不出用处——通根与透出是一件事的两面。',
480
+ url: 'https://www.diancang.xyz/xuanxuewushu/ditiansui/153091.html',
481
+ // 2026-11-01 复核:这一句是《子平真诠》论十干十二支注文里明确引作《滴天髓》的话,
482
+ // 在滴天髓底本(干支总论)里能逐字对上,所以才是「通根透干」可核的原文。
483
+ note: '《子平真诠》卷一论十干十二支注文引作《滴天髓》语,底本干支总论原文可核。',
484
+ },
440
485
  ];
441
486
 
442
487
  const BOOK_OF = {
@@ -469,7 +514,14 @@ export const CARD_CITATIONS = {
469
514
  'bazi-congge-yuanwen': { refs: ['smt-congsha', 'smt-congcai'] },
470
515
  'bazi-tongguan': { refs: ['dt-tongguan'] },
471
516
  // ── 八字:通行说,暂未定位单一条文
472
- 'bazi-tonggen-tougan': { status: 'general', book: '滴天髓', chapter: '通神论', reason: '原文为「天干得一比肩,不如得支中一墓库」一路的取象,未定位到单一整句' },
517
+ 'bazi-tonggen-tougan': {
518
+ refs: ['dt-yiqi-tonggen'],
519
+ // 通行说法(「地支所藏之干,本静以待用…干以通根为美,支以透出为贵」)常被署成《滴天髓》,
520
+ // 2026-11-01 核过:滴天髓底本与《子平真诠》沈孝瞻原文(论十干十二支)里都查不到这句,
521
+ // 它出现在《子平真诠》评注本该章的注文(附支藏人元图、人元司令表之后的按语)。
522
+ // 注文自身引的《滴天髓》「天全一气…」才是原本,故本卡只引可核的那句,注文只作说明不冒充原文。
523
+ note: '通行说两句出自《子平真诠》卷一注文,非《滴天髓》正文;注文所引的滴天髓句见本条。',
524
+ },
473
525
  'bazi-wangxiang': { refs: ['smt-wangxiang'] },
474
526
  'bazi-shensha': { refs: ['smt-shensha'] },
475
527
  'bazi-tiaohou': { refs: ['qt-tiaohou'] },
@@ -503,11 +555,13 @@ export const CARD_CITATIONS = {
503
555
  'liuyao-fuyin': { refs: ['zs-fuyin'] },
504
556
  'liuyao-jintui': { refs: ['zs-jintui'] },
505
557
  'liuyao-yingqi': { refs: ['zs-yingqi'] },
506
- 'liuyao-jintui-yong': { status: 'general', book: '增删卜易', chapter: '進神退神章、用神章', reason: '底本把进退落在「動而化」的吉凶喜忌与应期上,未见单列「用神化進/化退」条;按用神与忌神分档是本插件引申,不冒充原文' },
558
+ // 2026-11-01 复核:底本卷二·進神退神章第二十九原文「所喜者宜於化進,所忌者宜化退神」
559
+ // 已经把喜忌落在进退上,可作本条依据;把「喜/忌」具体落到用神/忌神两档仍是本插件的引申(见知识卡 note)
560
+ 'liuyao-jintui-yong': { refs: ['zs-jintui'] },
507
561
  'liuyao-yingqi-exact': { status: 'todo', book: '增删卜易', chapter: '各門類應期總注章', reason: '逐条应期尚未实现,等 20+ 条有应验日期的卦例' },
508
562
  // ── 其他体系
509
563
  'meihua-tiyong': { refs: ['mh-tiyong'] },
510
- 'daliuren-sike': { status: 'general', book: '大六壬大全', chapter: '九宗门取传法', reason: '原文待录入;本环境无稳定可核验的公有领域章页' },
564
+ 'daliuren-sike': { refs: ['dl-jigong', 'dl-zeike'] },
511
565
  'qimen-yinyang': { refs: ['qm-yinyang'] },
512
566
  'qimen-jixing': { refs: ['qm-jixing'] },
513
567
  'qimen-rumu': { refs: ['qm-rumu'] },
package/engine/diff.js CHANGED
@@ -353,3 +353,57 @@ export const CALIBER_SWITCHES = {
353
353
  },
354
354
  ],
355
355
  };
356
+
357
+ /**
358
+ * 口径并排:把「换口径后哪几条结论变了」抽成两列表格。
359
+ * 只读结果里的既有字段,不重算、不判定哪套更准;字段缺省时留空,不编值。
360
+ */
361
+ export const CALIBER_VIEWS = {
362
+ bazi: [
363
+ { path: 'pillars.day.ganzhi', label: '日柱' },
364
+ { path: 'pillars.hour.ganzhi', label: '时柱' },
365
+ { path: 'strength.level', label: '旺衰' },
366
+ { path: 'frame.primaryName', label: '格局' },
367
+ { path: 'frame.yongshen.favor', label: '喜用' },
368
+ { path: 'dayun.jiao.at', label: '起运' },
369
+ ],
370
+ ziwei: [
371
+ { path: 'soul.ganzhi', label: '命宫' },
372
+ { path: 'body.branch', label: '身宫支' },
373
+ { path: 'bureau.name', label: '五行局' },
374
+ { path: 'bodyMaster', label: '身主' },
375
+ { path: 'daxianList.0.ages', label: '起大限' },
376
+ ],
377
+ qimen: [
378
+ { path: 'siZhu.day', label: '日柱' },
379
+ { path: 'ju', label: '局数' },
380
+ { path: 'zhiFu.star', label: '值符' },
381
+ { path: 'zhiShi.gate', label: '值使' },
382
+ ],
383
+ };
384
+
385
+ function pickPath(obj, path) {
386
+ let cur = obj;
387
+ for (const seg of String(path).split('.')) {
388
+ if (cur === null || cur === undefined) return undefined;
389
+ cur = cur[seg];
390
+ }
391
+ return cur;
392
+ }
393
+
394
+ /** 并排单元格:数组用「、」串起,缺省留空,不把 undefined 写成字符串 */
395
+ export function viewCell(value) {
396
+ if (value === undefined || value === null) return '—';
397
+ if (Array.isArray(value)) return value.join('、');
398
+ if (typeof value === 'object') return '—';
399
+ return String(value);
400
+ }
401
+
402
+ export function viewRows(system, current, alt) {
403
+ const defs = CALIBER_VIEWS[system] || [];
404
+ return defs.map((d) => {
405
+ const a = viewCell(pickPath(current, d.path));
406
+ const b = viewCell(pickPath(alt, d.path));
407
+ return { key: d.path, label: d.label, current: a, alt: b, changed: a !== b };
408
+ });
409
+ }
package/engine/hepan.js CHANGED
@@ -61,6 +61,49 @@ function yearBranchRelation(a, b) {
61
61
  return { name: '无直接关系', score: 0, text: '属相之间没有合冲刑害,表层影响小,以日柱为主' };
62
62
  }
63
63
 
64
+ /**
65
+ * 三方合盘:把三对两两结果并列,不合成「三人总分」。
66
+ * 任何两两关系都独立成立,合成一个数会把「和谁相处」混为一谈;
67
+ * 这里只做三件事:列出每对的分数与三维度、指出最顺/最需磨合的一对、找出差异最大的一维。
68
+ * @param {{key:string,a:string,b:string,result:object}[]} pairs 三对结果(顺序固定为 a-b、a-c、b-c)
69
+ */
70
+ export function tripleOf(pairs) {
71
+ const brief = (r) => {
72
+ const s = (r && r.score) || {};
73
+ return {
74
+ total: typeof s.total === 'number' ? s.total : null,
75
+ level: s.level || '',
76
+ dimensions: (Array.isArray(r && r.dimensions) ? r.dimensions : [])
77
+ .map((d) => ({ key: d.key, label: d.label, score: typeof d.score === 'number' ? d.score : 0 })),
78
+ };
79
+ };
80
+ const rows = pairs.map((p) => ({ key: p.key, a: p.a, b: p.b, ...brief(p.result) }));
81
+ const sorted = rows.filter((x) => typeof x.total === 'number').sort((x, y) => y.total - x.total);
82
+ const best = sorted[0] || null;
83
+ const hardest = sorted[sorted.length - 1] || null;
84
+ // 同一维度在三对里差得最多的一维:三方之间最敏感的地方
85
+ let focus = null;
86
+ const keys = rows[0] ? rows[0].dimensions.map((d) => d.key) : [];
87
+ for (const key of keys) {
88
+ const vals = rows.map((r) => (r.dimensions.find((d) => d.key === key) || {}));
89
+ const scores = vals.map((d) => (typeof d.score === 'number' ? d.score : 0));
90
+ const spread = Math.max(...scores) - Math.min(...scores);
91
+ if (!focus || spread > focus.spread) {
92
+ focus = { key, label: (vals[0] && vals[0].label) || key, spread };
93
+ }
94
+ }
95
+ return {
96
+ pairs: rows,
97
+ best: best ? { key: best.key, a: best.a, b: best.b, total: best.total, level: best.level } : null,
98
+ hardest: hardest && hardest !== best
99
+ ? { key: hardest.key, a: hardest.a, b: hardest.b, total: hardest.total, level: hardest.level }
100
+ : null,
101
+ spread: best && hardest ? best.total - hardest.total : 0,
102
+ focus,
103
+ note: '三方只把三对两两关系并列,不合成「三人总分」:和不同的人相处是不同的事,合成一个数会把差异抹平。',
104
+ };
105
+ }
106
+
64
107
  /** 合盘主函数 */
65
108
  export function buildHepan(input) {
66
109
  assertCivil(input && input.a, 'a');