dsh-session-index-lau 2.4.1 → 2.5.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.
- package/README.md +164 -14
- package/dist/dsh-session-index-lau-v2.4.1.zip +0 -0
- package/dist/session-index-skill-v2.4.1.zip +0 -0
- package/install.cmd +2 -2
- package/install.sh +2 -2
- package/lib/index-store.mjs +20 -1
- package/lib/stage.mjs +231 -0
- package/package.json +3 -2
- package/plugin.json +6 -5
- package/server.mjs +131 -1
- package/smoke.mjs +67 -4
- package/tools/push-doc.mjs +335 -0
- package/update.cmd +2 -2
- package/update.sh +2 -2
- package//346/223/215/344/275/234/344/275/277/347/224/250/346/211/213/345/206/214.html +72 -28
package/server.mjs
CHANGED
|
@@ -26,9 +26,10 @@ import { loadNotes, saveNotes, addNote, queryNotes, removeNote, noteChunks, note
|
|
|
26
26
|
import { expandQuery, matchesQuery, rankCandidates, rrfMerge, makeSnippet } from './lib/semantic.mjs';
|
|
27
27
|
import { embeddingConfig, syncVectors, vectorRank, cardChunks, vectorStats } from './lib/embedding.mjs';
|
|
28
28
|
import { pack, packAll, listPacks } from './lib/pack.mjs';
|
|
29
|
+
import { stageConfig, buildStageDocument, stageToken, bridgeOverview, findDuplicate, postStage, StageError } from './lib/stage.mjs';
|
|
29
30
|
|
|
30
31
|
const { sessionsRoot, indexDir, workbuddyProjectsDir } = defaultPaths();
|
|
31
|
-
const VERSION = '2.
|
|
32
|
+
const VERSION = '2.5.2';
|
|
32
33
|
const SUPPORTED_PROTOCOLS = new Set(['2024-11-05', '2025-03-26', '2025-06-18']);
|
|
33
34
|
const log = (...parts) => process.stderr.write(`[dsh-session-index-lau] ${parts.join(' ')}\n`);
|
|
34
35
|
|
|
@@ -557,6 +558,130 @@ async function toolRefresh(args = {}) {
|
|
|
557
558
|
}
|
|
558
559
|
}
|
|
559
560
|
|
|
561
|
+
/**
|
|
562
|
+
* 控制器:会话整理文档 → 思源桥待入库队列。
|
|
563
|
+
* 只负责解析参数、组装源数据、格式化响应;渲染与 HTTP 都在 lib/stage.mjs。
|
|
564
|
+
* 两步确认:不带 confirmToken 一律只预览,带对 token 才投递;永不写库。
|
|
565
|
+
*/
|
|
566
|
+
async function toolStage(args = {}) {
|
|
567
|
+
const cfg = stageConfig();
|
|
568
|
+
if (!cfg.enabled) {
|
|
569
|
+
return { ok: false, code: 'STAGE_DISABLED', error: '暂存投递已被关闭', hint: '设置 SESSION_INDEX_STAGE=1 后重启 MCP 服务' };
|
|
570
|
+
}
|
|
571
|
+
const asError = (err) => (err instanceof StageError ? err.toJSON()
|
|
572
|
+
: { ok: false, code: 'STAGE_FAILED', error: String(err.message || err) });
|
|
573
|
+
|
|
574
|
+
if (args.status === true) {
|
|
575
|
+
try {
|
|
576
|
+
return await bridgeOverview(cfg);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return asError(err);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const template = ['brief', 'decisions', 'detail'].includes(args.template) ? args.template : 'brief';
|
|
583
|
+
const app = args.app || 'all';
|
|
584
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
585
|
+
const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';
|
|
586
|
+
const limit = clamp(args.limit, 1, 20, 8);
|
|
587
|
+
const maxChars = clamp(args.maxChars, 1000, 30000, 12000);
|
|
588
|
+
const ids = Array.isArray(args.sessionIds) ? args.sessionIds.filter((x) => typeof x === 'string' && x.trim()) : [];
|
|
589
|
+
|
|
590
|
+
// 源数据:指定会话时直接取卡片,否则用 brief 的最近/主题聚合
|
|
591
|
+
let sessions;
|
|
592
|
+
let memories;
|
|
593
|
+
if (ids.length) {
|
|
594
|
+
const cards = Object.values(freshCards().cards || {});
|
|
595
|
+
const picked = cards.filter((c) => ids.some((p) => String(c.id || '').startsWith(p))).slice(0, 20);
|
|
596
|
+
sessions = picked.map((c) => ({
|
|
597
|
+
id: c.id, app: appOf(c), title: c.title, updatedAt: c.updatedAt, digest: c.digest,
|
|
598
|
+
decisions: (c.decisions || []).slice(0, 3), questions: (c.questions || []).slice(0, 1),
|
|
599
|
+
keywords: (c.keywords || []).slice(0, 6),
|
|
600
|
+
}));
|
|
601
|
+
memories = queryNotes(freshNotes(), { query, limit: 5 })
|
|
602
|
+
.map((r) => ({ type: r.note.type, text: String(r.note.text).slice(0, 200), importance: r.note.importance }));
|
|
603
|
+
} else {
|
|
604
|
+
const brief = toolBrief({ query, app, workspace, limit, maxChars, includeMemory: true });
|
|
605
|
+
sessions = brief.sessions || [];
|
|
606
|
+
memories = brief.memories || [];
|
|
607
|
+
}
|
|
608
|
+
if (args.since || args.until) {
|
|
609
|
+
const since = Number(args.since) || 0;
|
|
610
|
+
const until = Number(args.until) || Infinity;
|
|
611
|
+
sessions = sessions.filter((s) => (s.updatedAt || 0) >= since && (s.updatedAt || 0) <= until);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let hits = [];
|
|
615
|
+
if (template === 'detail' && (query || ids.length)) {
|
|
616
|
+
try {
|
|
617
|
+
const sr = query ? await toolSearch({ query, app, workspace, mode: 'semantic', limit: 8, contextChars: 260 }) : null;
|
|
618
|
+
hits = (sr?.results || []).map((r) => ({
|
|
619
|
+
title: r.title, sessionId: r.sessionId, app: r.app, seq: r.seq,
|
|
620
|
+
snippet: r.snippet || r.text || '',
|
|
621
|
+
}));
|
|
622
|
+
} catch (err) {
|
|
623
|
+
log('stage detail 片段检索降级: ' + err.message);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const payloadOpts = {
|
|
628
|
+
notebook: args.notebook || cfg.defaultNotebook,
|
|
629
|
+
hpath: args.hpath || cfg.defaultHpath,
|
|
630
|
+
note: typeof args.note === 'string' ? args.note : '',
|
|
631
|
+
template,
|
|
632
|
+
};
|
|
633
|
+
let doc;
|
|
634
|
+
try {
|
|
635
|
+
doc = buildStageDocument({ sessions, memories, hits }, {
|
|
636
|
+
...payloadOpts, query, title: typeof args.title === 'string' ? args.title : '', app,
|
|
637
|
+
});
|
|
638
|
+
} catch (err) {
|
|
639
|
+
return asError(err);
|
|
640
|
+
}
|
|
641
|
+
const token = stageToken(doc, payloadOpts);
|
|
642
|
+
|
|
643
|
+
// 第一步:只预览,绝不产生任何写操作
|
|
644
|
+
if (!args.confirmToken) {
|
|
645
|
+
return {
|
|
646
|
+
ok: true, dryRun: true, staged: false, token,
|
|
647
|
+
title: doc.title, chars: doc.chars, template,
|
|
648
|
+
sessions: sessions.length, memories: memories.length, hits: hits.length,
|
|
649
|
+
target: { service: cfg.url, notebook: payloadOpts.notebook, hpath: payloadOpts.hpath },
|
|
650
|
+
preview: doc.markdown.slice(0, 1200),
|
|
651
|
+
previewTruncated: doc.markdown.length > 1200,
|
|
652
|
+
confirmWith: {
|
|
653
|
+
query, app, workspace, template, title: doc.title, sessionIds: ids.length ? ids : undefined,
|
|
654
|
+
since: args.since, until: args.until, limit, maxChars,
|
|
655
|
+
notebook: args.notebook, hpath: args.hpath, note: args.note, confirmToken: token,
|
|
656
|
+
},
|
|
657
|
+
hint: '仅预览,未投递。确认无误后把 confirmWith 原样作为本工具参数再调一次;本工具永远不会自动写入思源笔记库',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 第二步:令牌必须匹配(防止预览 A、投递 B)
|
|
662
|
+
if (String(args.confirmToken) !== token) {
|
|
663
|
+
return {
|
|
664
|
+
ok: false, code: 'TOKEN_MISMATCH', staged: false,
|
|
665
|
+
error: '确认令牌与当前内容不一致,已拒绝投递',
|
|
666
|
+
hint: '索引可能刚更新或参数被改动。请重新预览,并原样带回新的 confirmWith',
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
const dup = await findDuplicate(cfg, doc);
|
|
672
|
+
if (dup) {
|
|
673
|
+
return {
|
|
674
|
+
ok: true, staged: false, alreadyQueued: true, id: dup.id, title: doc.title, chars: doc.chars,
|
|
675
|
+
hint: '待入库队列里已有同标题同字数的稿,未重复投递',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const result = await postStage(cfg, doc, payloadOpts);
|
|
679
|
+
return { ...result, token, sessions: sessions.length, template, duplicateChecked: true };
|
|
680
|
+
} catch (err) {
|
|
681
|
+
return asError(err);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
560
685
|
const HANDLERS = {
|
|
561
686
|
session_index_status: toolStatus,
|
|
562
687
|
session_index_brief: toolBrief,
|
|
@@ -571,6 +696,7 @@ const HANDLERS = {
|
|
|
571
696
|
session_index_package: toolPackage,
|
|
572
697
|
session_index_vectors: toolVectors,
|
|
573
698
|
session_index_refresh: toolRefresh,
|
|
699
|
+
session_index_stage: toolStage,
|
|
574
700
|
};
|
|
575
701
|
|
|
576
702
|
// ---------------------------------------------------------------- MCP 声明与自描述
|
|
@@ -589,6 +715,8 @@ const GUIDE_TEXT = `# dsh-session-index-lau 使用说明
|
|
|
589
715
|
- 沉淀结论:session_index_remember;召回:session_index_recall;归档:session_index_forget。
|
|
590
716
|
- 打包分发:session_index_package(plugin / skill / all,自动生成 zip)。
|
|
591
717
|
- 查看索引与自动维护状态:session_index_status(autoIndex.watch 可看事件监听状态)。
|
|
718
|
+
- 会话整理成文档送进思源:session_index_stage(两步确认,只投递到「待入库」)。
|
|
719
|
+
- 探测思源桥服务与队列:session_index_stage {status:true}(只读)。
|
|
592
720
|
|
|
593
721
|
## 铁律
|
|
594
722
|
1. 逐级放大:brief → related/thread → search(片段) → get(原文)。
|
|
@@ -610,6 +738,7 @@ const INSTRUCTIONS = [
|
|
|
610
738
|
'会话开始或用户提到「之前/上次/继续/记得」时,先调 session_index_brief 接上跨会话结论与长期记忆。',
|
|
611
739
|
'找具体报错或命令用 session_index_search(默认语义检索,只返回片段);看完整原文用 session_index_get 传 seqs。',
|
|
612
740
|
'有新结论时用 session_index_remember 沉淀;需要分发时用 session_index_package 自动打包。',
|
|
741
|
+
'要把会话整理成文档送进思源笔记时,用 session_index_stage:先不带 confirmToken 预览,用户确认后原样回传 confirmWith 才投递到「待入库」;绝不自动写库。',
|
|
613
742
|
'完整说明见资源 session-index://guide,不要直接读取 .zstd 源会话文件。',
|
|
614
743
|
].join(' ');
|
|
615
744
|
|
|
@@ -627,6 +756,7 @@ const TOOLS = [
|
|
|
627
756
|
{ name: 'session_index_package', description: '【自动打包】无需手工压缩,一键生成可分发 zip。mode=plugin 打完整插件包,skill 打技能包(可直接导入 DSH 技能管理器),all 两种都打,status 查看已生成包。包内自动附 MANIFEST.json 与 INSTALL.md,且排除所有会话数据/索引/密钥。', inputSchema: { type: 'object', properties: { mode: { type: 'string', description: 'plugin / skill / all(默认) / status' } }, additionalProperties: false } },
|
|
628
757
|
{ name: 'session_index_vectors', description: '【可选向量层】查看/同步/查询向量缓存。未配置 SESSION_INDEX_EMBED_* 时本地语义检索照常工作且零成本。', inputSchema: { type: 'object', properties: { action: { type: 'string', description: 'status/sync/search' }, query: { type: 'string' }, limit: { type: 'number' } }, additionalProperties: false } },
|
|
629
758
|
{ name: 'session_index_refresh', description: '手动立即刷新索引。注意:新会话已由文件事件自动识别,通常无需调用;仅改抽取规则或需即时生效时用。', inputSchema: { type: 'object', properties: { force: { type: 'boolean' } }, additionalProperties: false } },
|
|
759
|
+
{ name: 'session_index_stage', description: '【会话整理 → 思源桥待入库】把会话卡/结论/检索片段整理成 Markdown 文档。两步确认:不带 confirmToken 只返回预览与令牌,原样回传 confirmWith 才投递到思源桥 /api/stage;绝不自动写入思源笔记库。支持指定会话、主题、模板、落点与去重。status=true 只读探测服务与队列。', inputSchema: { type: 'object', properties: { status: { type: 'boolean', description: '只读:探测思源桥服务与待入库队列' }, query: { type: 'string', description: '主题词,按主题聚合会话' }, sessionIds: { type: 'array', items: { type: 'string' }, description: '指定会话 id(支持前缀),不传则按最近/主题取' }, app: { type: 'string', description: 'dsh / workbuddy / all' }, workspace: { type: 'string' }, since: { type: 'number' }, until: { type: 'number' }, template: { type: 'string', description: 'brief(默认) / decisions / detail' }, title: { type: 'string', description: '自定义文档标题;确认时请回传预览给出的 title' }, notebook: { type: 'string' }, hpath: { type: 'string', description: '目标目录,默认 /会话札记/思源' }, note: { type: 'string' }, limit: { type: 'number' }, maxChars: { type: 'number' }, confirmToken: { type: 'string', description: '预览返回的令牌;带上且匹配才真正投递' } }, additionalProperties: false } },
|
|
630
760
|
];
|
|
631
761
|
|
|
632
762
|
const PROMPTS = [
|
package/smoke.mjs
CHANGED
|
@@ -7,17 +7,53 @@
|
|
|
7
7
|
* 测试写入的记忆会在结尾物理清除,不污染真实记忆库。
|
|
8
8
|
*/
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
+
import http from 'node:http';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
|
|
13
14
|
import { inspectZip } from './lib/pack.mjs';
|
|
14
15
|
|
|
16
|
+
// 本地假思源桥:完整实现 status / stage / autostage 三个只读端点与 POST /api/stage,
|
|
17
|
+
// 用来验证两步确认与去重,绝不碰 5599 上真实的「待入库」队列。
|
|
18
|
+
const stagePosts = [];
|
|
19
|
+
const stagedItems = [];
|
|
20
|
+
const mockBridge = http.createServer((req, res) => {
|
|
21
|
+
const send = (code, obj) => {
|
|
22
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
23
|
+
res.end(JSON.stringify(obj));
|
|
24
|
+
};
|
|
25
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
26
|
+
if (req.method === 'GET' && u.pathname === '/api/status') return send(200, { kernel: '3.8.9', staged: stagedItems.length, notebooks: [] });
|
|
27
|
+
if (req.method === 'GET' && u.pathname === '/api/stage') return send(200, { items: stagedItems, discarded: 0 });
|
|
28
|
+
if (req.method === 'GET' && u.pathname === '/api/autostage') return send(200, { enabled: true, dir: 'D:/mock/session-docs', hpath: '/会话札记/思源', files: [] });
|
|
29
|
+
if (req.method === 'POST' && u.pathname === '/api/stage') {
|
|
30
|
+
let raw = '';
|
|
31
|
+
req.on('data', (c) => { raw += c; });
|
|
32
|
+
req.on('end', () => {
|
|
33
|
+
const p = JSON.parse(raw || '{}');
|
|
34
|
+
stagePosts.push(p);
|
|
35
|
+
const item = { id: '20261114140000-abcdef', title: p.title, chars: String(p.markdown || '').length, hpath: p.hpath, notebook: p.notebook, note: p.note };
|
|
36
|
+
stagedItems.push(item);
|
|
37
|
+
send(200, { id: item.id, title: item.title, chars: item.chars, staged: true });
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (u.pathname.includes('/commit')) {
|
|
42
|
+
stagePosts.push({ __forbidden: 'commit' });
|
|
43
|
+
return send(200, {});
|
|
44
|
+
}
|
|
45
|
+
return send(404, { error: 'not found' });
|
|
46
|
+
});
|
|
47
|
+
await new Promise((r) => mockBridge.listen(0, '127.0.0.1', r));
|
|
48
|
+
const stageUrl = `http://127.0.0.1:${mockBridge.address().port}`;
|
|
49
|
+
|
|
15
50
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
51
|
const child = spawn(process.execPath, [path.join(here, 'server.mjs')], {
|
|
17
52
|
cwd: here,
|
|
18
53
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
54
|
env: {
|
|
20
55
|
...process.env,
|
|
56
|
+
SIYUAN_BRIDGE_URL: stageUrl,
|
|
21
57
|
SESSION_INDEX_WATCH_DEBOUNCE_MS: '60000',
|
|
22
58
|
SESSION_INDEX_AUTO_INTERVAL_MS: '3600000',
|
|
23
59
|
},
|
|
@@ -92,16 +128,16 @@ try {
|
|
|
92
128
|
const init = await request('initialize', {
|
|
93
129
|
protocolVersion: '2024-11-05',
|
|
94
130
|
capabilities: {},
|
|
95
|
-
clientInfo: { name: 'smoke-test', version: '2.
|
|
131
|
+
clientInfo: { name: 'smoke-test', version: '2.5.2' },
|
|
96
132
|
});
|
|
97
|
-
check('initialize 握手与能力声明', init.result?.serverInfo?.version === '2.
|
|
133
|
+
check('initialize 握手与能力声明', init.result?.serverInfo?.version === '2.5.2' && Boolean(init.result?.capabilities?.prompts) && Boolean(init.result?.capabilities?.resources), init.result?.serverInfo?.version);
|
|
98
134
|
check('initialize 返回自描述 instructions', (init.result?.instructions || '').includes('session_index_brief'));
|
|
99
135
|
check('声明 listChanged 通知能力', init.result?.capabilities?.resources?.listChanged === true && init.result?.capabilities?.prompts?.listChanged === true);
|
|
100
136
|
notify('notifications/initialized');
|
|
101
137
|
|
|
102
138
|
const listed = await request('tools/list');
|
|
103
139
|
const names = (listed.result?.tools || []).map((t) => t.name);
|
|
104
|
-
check('tools/list 返回
|
|
140
|
+
check('tools/list 返回 14 个工具', names.length === 14 && names.includes('session_index_stage'), `${names.length} 个`);
|
|
105
141
|
check('ping', (await request('ping')).result !== undefined);
|
|
106
142
|
|
|
107
143
|
// 自动索引守护 + 文件事件监听
|
|
@@ -215,7 +251,7 @@ try {
|
|
|
215
251
|
check(`resources/read ${uri.split('//')[1]}`, (r.result?.contents?.[0]?.text || '').length > 10);
|
|
216
252
|
}
|
|
217
253
|
const pluginJson = JSON.parse((await request('resources/read', { uri: 'session-index://plugin.json' })).result.contents[0].text);
|
|
218
|
-
check('插件清单机器可读', pluginJson.mcp?.toolCount ===
|
|
254
|
+
check('插件清单机器可读', pluginJson.mcp?.toolCount === 14 && pluginJson.automatic?.watchNewSessions === true, `v${pluginJson.version}`);
|
|
219
255
|
const cardUri = await request('resources/read', { uri: `session-index://card/${firstId.slice(0, 12)}` });
|
|
220
256
|
check('resources/read 单会话卡模板', (cardUri.result?.contents?.[0]?.text || '').includes('"id"'));
|
|
221
257
|
|
|
@@ -225,6 +261,32 @@ try {
|
|
|
225
261
|
const refreshed = await payload(await call('session_index_refresh', { force: false }));
|
|
226
262
|
check('refresh 手动刷新仍可用', refreshed.ok === true && refreshed.cardsIndexed > 0);
|
|
227
263
|
check('refresh 返回自动维护状态', Boolean(refreshed.autoIndex));
|
|
264
|
+
// ---- 会话整理 → 思源桥待入库(本地假服务,全程不碰真实 5599 队列)
|
|
265
|
+
const ov = await payload(await call('session_index_stage', { status: true }));
|
|
266
|
+
check('stage 只读探测服务与队列', ov.ok === true && ov.kernel === '3.8.9' && ov.queued === 0, JSON.stringify({ kernel: ov.kernel, queued: ov.queued }));
|
|
267
|
+
check('stage 探测返回自动接入目录', ov.autoStageDir === 'D:/mock/session-docs' && ov.autoStageEnabled === true);
|
|
268
|
+
check('stage 探测不产生写入', stagePosts.length === 0);
|
|
269
|
+
|
|
270
|
+
const prev = await payload(await call('session_index_stage', { query: '插件', template: 'brief', limit: 3 }));
|
|
271
|
+
check('stage 默认只预览不投递', prev.dryRun === true && prev.staged === false && typeof prev.token === 'string' && stagePosts.length === 0, prev.code || prev.error || '');
|
|
272
|
+
check('stage 预览含 Markdown 与落点', String(prev.preview || '').includes('# 会话整理') && prev.target?.hpath === '/会话札记/思源');
|
|
273
|
+
check('stage 预览给出可回传的 confirmWith', prev.confirmWith?.confirmToken === prev.token && typeof prev.confirmWith?.title === 'string');
|
|
274
|
+
|
|
275
|
+
const bad = await payload(await call('session_index_stage', { ...prev.confirmWith, confirmToken: 'deadbeef' }));
|
|
276
|
+
check('stage 错误令牌被拒绝', bad.ok === false && bad.code === 'TOKEN_MISMATCH' && bad.staged === false, bad.code || '');
|
|
277
|
+
check('stage 拒绝后仍无写入', stagePosts.length === 0);
|
|
278
|
+
|
|
279
|
+
const posted = await payload(await call('session_index_stage', prev.confirmWith));
|
|
280
|
+
check('stage 确认后投递成功', posted.ok === true && posted.staged === true && posted.id === '20261114140000-abcdef' && stagePosts.length === 1, posted.code || posted.error || '');
|
|
281
|
+
check('stage 投递体含标题与 Markdown', typeof stagePosts[0]?.title === 'string' && String(stagePosts[0]?.markdown || '').includes('# 会话整理'));
|
|
282
|
+
check('stage 文档声明未写入笔记库', /未写入笔记库/.test(String(stagePosts[0]?.markdown || '')));
|
|
283
|
+
check('stage 绝不调用 commit', stagePosts.every((p) => !p.__forbidden));
|
|
284
|
+
|
|
285
|
+
const dup = await payload(await call('session_index_stage', prev.confirmWith));
|
|
286
|
+
check('stage 重复投递被去重', dup.alreadyQueued === true && dup.staged === false && stagePosts.length === 1, dup.code || '');
|
|
287
|
+
const dec = await payload(await call('session_index_stage', { template: 'decisions', limit: 2 }));
|
|
288
|
+
check('stage decisions 模板可用', dec.dryRun === true && dec.chars > 0);
|
|
289
|
+
|
|
228
290
|
check('空 query 被规范化拒绝', (await call('session_index_search', { query: '' })).result?.isError === true);
|
|
229
291
|
check('通知通道可用(声明级)', Array.isArray(notifications), `收到 ${notifications.length} 条通知`);
|
|
230
292
|
} catch (err) {
|
|
@@ -239,6 +301,7 @@ try {
|
|
|
239
301
|
}
|
|
240
302
|
child.stdin.end();
|
|
241
303
|
child.kill();
|
|
304
|
+
mockBridge.close();
|
|
242
305
|
}
|
|
243
306
|
|
|
244
307
|
const failed = results.filter((r) => !r.ok).length;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把任意文档(Markdown / HTML)推送到思源桥「待入库」队列。
|
|
3
|
+
*
|
|
4
|
+
* 三种用法:
|
|
5
|
+
* node tools/push-doc.mjs 单文件预览(默认《操作使用手册.html》)
|
|
6
|
+
* node tools/push-doc.mjs --file README.md --send 推送任意单个文档
|
|
7
|
+
* node tools/push-doc.mjs --from-workbuddy 批量:列出 WorkBuddy 会话文档(只读)
|
|
8
|
+
* node tools/push-doc.mjs --from-workbuddy --send 批量:全部投递到待入库
|
|
9
|
+
* node tools/push-doc.mjs --from-workbuddy --seed 批量:把当前文档记为「已推送」(不投递)
|
|
10
|
+
*
|
|
11
|
+
* 批量数据源:~/.workbuddy/artifact-index/<会话id>.json 的 contentType=document 条目。
|
|
12
|
+
* 默认排除临时验证目录/依赖目录/旧开发副本,按内容哈希去重,避免把同一份 README
|
|
13
|
+
* 推十几遍。用 --no-exclude 关闭排除,--source 按生成方式收窄,--limit 设上限。
|
|
14
|
+
*
|
|
15
|
+
* 判重(对齐思源桥 autoStage 的 .auto-staged.json):状态文件按「文档路径 + 内容哈希」
|
|
16
|
+
* 持久化记录已推送过的文档,**不依赖队列当前状态**。所以在思源桥里「丢弃」某篇后,
|
|
17
|
+
* 下次自动化不会把它重新推送回来;只有内容真的变了才会作为新版本再投一次。
|
|
18
|
+
* --force 可绕过状态与队列检查;--seed 用于首次启用时把历史文档补进状态基线。
|
|
19
|
+
*
|
|
20
|
+
* 可控点:默认 dry-run;--send 才入队;**永远不调用 /api/stage/commit**,
|
|
21
|
+
* 写入思源笔记库由人在界面确认。
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
const arg = (name, fallback) => {
|
|
32
|
+
const i = argv.indexOf(name);
|
|
33
|
+
return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
|
|
34
|
+
};
|
|
35
|
+
const has = (...names) => names.some((n) => argv.includes(n));
|
|
36
|
+
const SEND = has('--send');
|
|
37
|
+
const SEED = has('--seed');
|
|
38
|
+
const BATCH = has('--from-workbuddy');
|
|
39
|
+
const NO_EXCLUDE = has('--no-exclude');
|
|
40
|
+
const FORCE = has('--force');
|
|
41
|
+
const LIMIT = Math.max(1, Math.min(500, Number(arg('--limit', 60)) || 60));
|
|
42
|
+
const SOURCE = arg('--source', '');
|
|
43
|
+
const SINCE = Number(arg('--since', 0)) || 0;
|
|
44
|
+
const FILE = path.resolve(ROOT, arg('--file', '操作使用手册.html'));
|
|
45
|
+
const BRIDGE = (process.env.SIYUAN_BRIDGE_URL || `http://127.0.0.1:${process.env.SIYUAN_BRIDGE_PORT || 5599}`).replace(/\/+$/, '');
|
|
46
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
|
47
|
+
const HPATH = arg('--hpath', process.env.SESSION_INDEX_STAGE_HPATH || '/会话札记/思源');
|
|
48
|
+
const NOTEBOOK = arg('--notebook', '思源桥');
|
|
49
|
+
const TITLE_OVERRIDE = arg('--title', '');
|
|
50
|
+
const OUT_DIR = path.join(ROOT, 'dist');
|
|
51
|
+
// 和思源桥 .auto-staged.json 同类的持久化状态:判重只看这里,不看队列里还在不在
|
|
52
|
+
const STATE_FILE = process.env.PUSH_DOC_STATE
|
|
53
|
+
|| path.join(process.env.SESSION_INDEX_DIR || path.join(os.homedir(), '.dsh', 'session-index'), 'push-doc-state.json');
|
|
54
|
+
|
|
55
|
+
function loadState() {
|
|
56
|
+
try {
|
|
57
|
+
const j = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
58
|
+
if (j && typeof j.pushed === 'object' && j.pushed) return j;
|
|
59
|
+
} catch {
|
|
60
|
+
// 首次运行或文件损坏,按空状态处理
|
|
61
|
+
}
|
|
62
|
+
return { version: 1, pushed: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveState(state) {
|
|
66
|
+
state.version = 1;
|
|
67
|
+
state.updatedAt = new Date().toISOString();
|
|
68
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
69
|
+
const tmp = `${STATE_FILE}.tmp-${process.pid}`;
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
71
|
+
// Windows 上目标被另一进程短暂占用时 rename 会 EPERM,做短退避
|
|
72
|
+
let last;
|
|
73
|
+
for (const delay of [0, 40, 120, 300]) {
|
|
74
|
+
if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
75
|
+
try {
|
|
76
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
77
|
+
return;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
last = err;
|
|
80
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
84
|
+
throw last;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 稳定键:文档相对路径;内容哈希单独比较,内容变了才算新版本。 */
|
|
88
|
+
const stateKey = (relShort) => String(relShort).replace(/\\/g, '/').toLowerCase();
|
|
89
|
+
|
|
90
|
+
/** 默认排除:临时验证副本、依赖目录、旧开发副本、UI 源码。 */
|
|
91
|
+
const EXCLUDE = [
|
|
92
|
+
/(^|\/)(node_modules|dist|\.git|__pycache__)(\/|$)/i,
|
|
93
|
+
/(^|\/)\.verify-/i,
|
|
94
|
+
/(^|\/)DSH-probe(\/|$)/i,
|
|
95
|
+
/(^|\/)dsh-session-index(\/|$)/i,
|
|
96
|
+
/(^|\/)backup-/i,
|
|
97
|
+
/\.backup-/i,
|
|
98
|
+
/\.audit-/i,
|
|
99
|
+
/(^|\/)service\/public\//i,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const api = async (p, init) => {
|
|
103
|
+
const r = await fetch(BRIDGE + p, init);
|
|
104
|
+
const j = await r.json().catch(() => ({}));
|
|
105
|
+
if (!r.ok) throw new Error(`${p} → HTTP ${r.status}: ${j.error || r.statusText}`);
|
|
106
|
+
return j;
|
|
107
|
+
};
|
|
108
|
+
const post = (p, body) => api(p, {
|
|
109
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
110
|
+
});
|
|
111
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
112
|
+
const cleanTitle = (s) => String(s).replace(/[\\/:*?"<>|\r\n]+/g, ' ').trim().slice(0, 110);
|
|
113
|
+
|
|
114
|
+
/** 把 HTML 正文裁干净后用思源桥自带转换器(只读)转 Markdown;MD 原样返回。 */
|
|
115
|
+
async function toMarkdown(raw, label) {
|
|
116
|
+
const isHtml = /^\s*<(!doctype|html|main|div|h[1-6])/i.test(raw);
|
|
117
|
+
if (!isHtml) return raw.trim();
|
|
118
|
+
const main = /<main>([\s\S]*?)<\/main>/i.exec(raw)?.[1] || raw;
|
|
119
|
+
const cleaned = main
|
|
120
|
+
.replace(/<button\b[^>]*>[\s\S]*?<\/button>/gi, '')
|
|
121
|
+
.replace(/<div class="badges">[\s\S]*?<\/div>\s*<\/header>/i, '</header>')
|
|
122
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, '')
|
|
123
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, '')
|
|
124
|
+
.replace(/\sdata-kw="[^"]*"/g, '')
|
|
125
|
+
.trim();
|
|
126
|
+
const converted = (await post('/api/paste/html', { html: cleaned })).markdown || '';
|
|
127
|
+
return converted.replace(/^\s*#\s+[^\n]*操作使用手册[^\n]*\s*\n/m, '').trim() || `# ${label}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 组装一篇待投递文档:统一头部元信息 + 正文。 */
|
|
131
|
+
async function buildDoc(raw, { title, origin, conv }) {
|
|
132
|
+
const body = await toMarkdown(raw, title);
|
|
133
|
+
const header = [
|
|
134
|
+
`# ${title}`,
|
|
135
|
+
'',
|
|
136
|
+
`> 来源:\`${origin}\`${conv ? ` · 会话 ${conv.slice(0, 8)}` : ''} · 插件 v${VERSION}`,
|
|
137
|
+
`> 转换:${/^\s*</.test(raw) ? '思源桥内置 HTML→Markdown' : '原样 Markdown'} · ${new Date().toLocaleString('zh-CN', { hour12: false })}`,
|
|
138
|
+
'> 状态:**仅进入待入库队列,未写入思源笔记库**,请在界面确认后再落库。',
|
|
139
|
+
'',
|
|
140
|
+
'---',
|
|
141
|
+
'',
|
|
142
|
+
'',
|
|
143
|
+
].join('\n');
|
|
144
|
+
const markdown = `${header}${body}\n`;
|
|
145
|
+
const problems = [];
|
|
146
|
+
for (const bad of ['已复制', 'btn.textContent', 'data-kw', '@media', '<style', 'function(']) {
|
|
147
|
+
const n = markdown.split(bad).length - 1;
|
|
148
|
+
if (n) problems.push(`${bad} × ${n}`);
|
|
149
|
+
}
|
|
150
|
+
return { title, markdown, problems, chars: markdown.length };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function shortPath(abs) {
|
|
154
|
+
const marker = 'Tool开发';
|
|
155
|
+
const i = abs.replace(/\\/g, '/').lastIndexOf(marker);
|
|
156
|
+
return i === -1 ? path.basename(abs) : abs.replace(/\\/g, '/').slice(i + marker.length + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 从 WorkBuddy 产物索引收集文档。 */
|
|
160
|
+
function collectWorkbuddyDocs() {
|
|
161
|
+
const dir = process.env.WORKBUDDY_ARTIFACT_INDEX
|
|
162
|
+
|| path.join(os.homedir(), '.workbuddy', 'artifact-index');
|
|
163
|
+
if (!fs.existsSync(dir)) throw new Error(`找不到 WorkBuddy 产物索引:${dir}`);
|
|
164
|
+
const seenHash = new Set();
|
|
165
|
+
const seenPath = new Set();
|
|
166
|
+
const docs = [];
|
|
167
|
+
let rawCount = 0;
|
|
168
|
+
let excluded = 0;
|
|
169
|
+
let missing = 0;
|
|
170
|
+
let dup = 0;
|
|
171
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
|
|
172
|
+
let j;
|
|
173
|
+
try {
|
|
174
|
+
j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const conv = f.replace(/\.json$/, '');
|
|
179
|
+
for (const a of j.artifacts || []) {
|
|
180
|
+
const docExt = has('--include-txt') ? /\.(md|markdown|html?|txt)$/i : /\.(md|markdown|html?)$/i;
|
|
181
|
+
if (a.contentType !== 'document' || !docExt.test(a.name || '')) continue;
|
|
182
|
+
if (SOURCE && String(a._meta?.sourceTool || '') !== SOURCE) continue;
|
|
183
|
+
if (SINCE && Number(a.updatedAt || 0) < SINCE) continue;
|
|
184
|
+
rawCount++;
|
|
185
|
+
let abs;
|
|
186
|
+
try {
|
|
187
|
+
abs = decodeURIComponent(String(a.uri || '').replace(/^file:\/\/\//, '')).replace(/\//g, path.sep);
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const rel = String(a.uri || '').replace(/^file:\/\/\//, '');
|
|
192
|
+
if (!NO_EXCLUDE && EXCLUDE.some((re) => re.test(rel))) { excluded++; continue; }
|
|
193
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { missing++; continue; }
|
|
194
|
+
const key = abs.toLowerCase();
|
|
195
|
+
if (seenPath.has(key)) { dup++; continue; }
|
|
196
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
197
|
+
const h = sha(raw);
|
|
198
|
+
if (seenHash.has(h)) { dup++; continue; }
|
|
199
|
+
seenPath.add(key);
|
|
200
|
+
seenHash.add(h);
|
|
201
|
+
const relShort = shortPath(abs);
|
|
202
|
+
const label = path.basename(abs);
|
|
203
|
+
docs.push({
|
|
204
|
+
abs, raw, conv,
|
|
205
|
+
relShort,
|
|
206
|
+
contentHash: h,
|
|
207
|
+
stateKey: stateKey(relShort),
|
|
208
|
+
title: cleanTitle(TITLE_OVERRIDE || `${label.replace(/\.[^.]+$/, '')} · ${relShort}`),
|
|
209
|
+
origin: relShort,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { docs, stats: { dir, rawCount, excluded, missing, dup, unique: docs.length } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function deliver(doc, queue, state) {
|
|
217
|
+
if (doc.problems.length) return { skipped: true, why: '含杂质:' + doc.problems.join(', ') };
|
|
218
|
+
if (doc.chars > 256 * 1024) return { skipped: true, why: '超过 256 KiB' };
|
|
219
|
+
if (!SEND) return { skipped: true, why: 'dry-run(未写入)' };
|
|
220
|
+
|
|
221
|
+
// 第一道:持久化状态。丢弃 / 清空队列 / 服务重启都不会让已推过的文档再出现。
|
|
222
|
+
const prev = state && doc.stateKey ? state.pushed[doc.stateKey] : null;
|
|
223
|
+
if (prev && prev.hash === doc.contentHash && !FORCE) {
|
|
224
|
+
return { skipped: true, why: '已推送过(丢弃也不重推)' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 第二道:队列同名(状态文件丢失时的兜底,不影响「丢弃后不重推」)
|
|
228
|
+
const same = queue.filter((it) => String(it.title).trim() === doc.title);
|
|
229
|
+
if (same.length && !FORCE) {
|
|
230
|
+
// 思源桥会规整 Markdown,队列里的 chars 与发送时未必相等,所以按标题判重更可靠
|
|
231
|
+
return { skipped: true, why: '队列中已有同名稿(--force 可再投)' };
|
|
232
|
+
}
|
|
233
|
+
const r = await post('/api/stage', {
|
|
234
|
+
title: doc.title, markdown: doc.markdown, notebook: NOTEBOOK, hpath: HPATH,
|
|
235
|
+
note: `WorkBuddy 会话产物 · ${doc.conv.slice(0, 8)} · v${VERSION} · 待人工确认`,
|
|
236
|
+
});
|
|
237
|
+
queue.push({ title: doc.title, chars: doc.chars });
|
|
238
|
+
if (state && doc.stateKey) {
|
|
239
|
+
state.pushed[doc.stateKey] = {
|
|
240
|
+
hash: doc.contentHash, title: doc.title, id: r.id || null, at: new Date().toISOString(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { staged: true, id: r.id, chars: r.chars };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ------------------------------------------------------------------ 主流程
|
|
247
|
+
|
|
248
|
+
const state = BATCH ? loadState() : null;
|
|
249
|
+
const queue = (SEND && !SEED) ? ((await api('/api/stage')).items || []) : [];
|
|
250
|
+
let jobs;
|
|
251
|
+
|
|
252
|
+
if (BATCH) {
|
|
253
|
+
const { docs, stats } = collectWorkbuddyDocs();
|
|
254
|
+
console.log('=== WorkBuddy 会话文档扫描 ===');
|
|
255
|
+
console.log(`索引目录:${stats.dir}`);
|
|
256
|
+
console.log(`命中文档条目 ${stats.rawCount} · 排除临时/依赖目录 ${stats.excluded} · 文件已不存在 ${stats.missing} · 路径或内容重复 ${stats.dup}`);
|
|
257
|
+
console.log(`唯一可推送 ${stats.unique} 篇,本次上限 ${LIMIT} 篇${NO_EXCLUDE ? '(已关闭默认排除)' : ''}${SOURCE ? ` · 仅 ${SOURCE}` : ''}`);
|
|
258
|
+
jobs = docs.slice(0, LIMIT);
|
|
259
|
+
if (!jobs.length) {
|
|
260
|
+
console.log('\n没有可推送的文档。');
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
console.log('\n清单:');
|
|
264
|
+
jobs.forEach((d, i) => console.log(` ${String(i + 1).padStart(2)}. [${d.conv.slice(0, 8)}] ${d.title}`));
|
|
265
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
266
|
+
|
|
267
|
+
// --seed:纯本地建立状态基线,不碰思源桥、不投递任何文档。
|
|
268
|
+
if (SEED) {
|
|
269
|
+
let added = 0;
|
|
270
|
+
let same = 0;
|
|
271
|
+
for (const j of jobs) {
|
|
272
|
+
const prev = state.pushed[j.stateKey];
|
|
273
|
+
if (prev && prev.hash === j.contentHash && !FORCE) { same++; continue; }
|
|
274
|
+
state.pushed[j.stateKey] = {
|
|
275
|
+
hash: j.contentHash, title: j.title, id: prev?.id || 'seeded', at: new Date().toISOString(),
|
|
276
|
+
};
|
|
277
|
+
added++;
|
|
278
|
+
}
|
|
279
|
+
saveState(state);
|
|
280
|
+
console.log(`\n状态基线:新增 ${added} 条 / 已存在 ${same} 条 / 共 ${Object.keys(state.pushed).length} 条`);
|
|
281
|
+
console.log(`状态文件:${STATE_FILE}`);
|
|
282
|
+
console.log('未投递任何文档,未写入思源笔记库。');
|
|
283
|
+
process.exit(0);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
if (!fs.existsSync(FILE)) throw new Error(`找不到文件:${FILE}`);
|
|
287
|
+
const raw = fs.readFileSync(FILE, 'utf8');
|
|
288
|
+
const base = path.basename(FILE);
|
|
289
|
+
jobs = [{
|
|
290
|
+
raw, conv: '', relShort: shortPath(FILE), origin: base,
|
|
291
|
+
title: cleanTitle(TITLE_OVERRIDE || (/\.html?$/i.test(FILE)
|
|
292
|
+
? `dsh-session-index 操作使用手册 v${VERSION}` : base.replace(/\.[^.]+$/, ''))),
|
|
293
|
+
}];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const results = [];
|
|
297
|
+
for (const j of jobs) {
|
|
298
|
+
const doc = await buildDoc(j.raw, { title: j.title, origin: BATCH ? j.origin : j.relShort, conv: j.conv });
|
|
299
|
+
if (!BATCH) {
|
|
300
|
+
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
301
|
+
fs.writeFileSync(path.join(OUT_DIR, 'push-doc-preview.md'), doc.markdown, 'utf8');
|
|
302
|
+
console.log(`\n文件:${FILE}`);
|
|
303
|
+
console.log(`转换:${j.raw.length} → ${doc.chars} 字 · 章节标题 ${doc.markdown.split('\n').filter((l) => /^#{1,3} /.test(l)).length} 个`);
|
|
304
|
+
console.log(`杂质检查:${doc.problems.length ? doc.problems.join(', ') : '干净 ✓'}`);
|
|
305
|
+
console.log(`预览:${path.join(OUT_DIR, 'push-doc-preview.md')}`);
|
|
306
|
+
console.log(`\n--- 前 12 行 ---\n${doc.markdown.split('\n').slice(0, 12).join('\n')}`);
|
|
307
|
+
}
|
|
308
|
+
results.push({
|
|
309
|
+
title: doc.title,
|
|
310
|
+
...(await deliver({
|
|
311
|
+
...doc, conv: j.conv || '', stateKey: j.stateKey, contentHash: j.contentHash,
|
|
312
|
+
}, queue, state)),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (BATCH && SEND && state && results.some((r) => r.staged)) saveState(state);
|
|
317
|
+
|
|
318
|
+
if (BATCH) {
|
|
319
|
+
const staged = results.filter((r) => r.staged);
|
|
320
|
+
const skipped = results.filter((r) => r.skipped);
|
|
321
|
+
const reasons = new Map();
|
|
322
|
+
for (const r of skipped) reasons.set(r.why, (reasons.get(r.why) || 0) + 1);
|
|
323
|
+
console.log(`\n=== 结果 ===`);
|
|
324
|
+
console.log(`投递成功 ${staged.length} 篇 / 跳过 ${skipped.length} 篇 / 队列现有 ${queue.length} 篇`);
|
|
325
|
+
for (const [why, n] of reasons) console.log(` ${why}: ${n}`);
|
|
326
|
+
if (staged.length) console.log(` 首篇 id=${staged[0].id} · 落点=${HPATH}`);
|
|
327
|
+
console.log(` 状态基线 ${Object.keys(state.pushed).length} 篇 · ${STATE_FILE}`);
|
|
328
|
+
if (!SEND) console.log('\n[只读预览] 未产生任何写入。确认清单后加 --send 才批量投递。');
|
|
329
|
+
else console.log('\n未调用 commit;请在思源桥「待入库」界面逐篇或批量勾选写入。');
|
|
330
|
+
} else {
|
|
331
|
+
const r = results[0];
|
|
332
|
+
if (r.staged) console.log(`\n已投递:id=${r.id} · chars=${r.chars} · hpath=${HPATH}\n未调用 commit。`);
|
|
333
|
+
else console.log(`\n未投递:${r.why}`);
|
|
334
|
+
if (!SEND) console.log('[只读预览] 确认后加 --send 才进入待入库队列。');
|
|
335
|
+
}
|
package/update.cmd
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
rem Pure ASCII output on purpose: cmd.exe mis-parses UTF-8 .cmd files.
|
|
3
3
|
setlocal EnableExtensions EnableDelayedExpansion
|
|
4
4
|
chcp 65001 >nul 2>nul
|
|
5
|
-
title dsh-session-index
|
|
5
|
+
title dsh-session-index updater
|
|
6
6
|
|
|
7
7
|
set "PLUGIN_DIR=%~dp0"
|
|
8
8
|
if "%PLUGIN_DIR:~-1%"=="\" set "PLUGIN_DIR=%PLUGIN_DIR:~0,-1%"
|
|
9
9
|
|
|
10
10
|
echo ============================================================
|
|
11
|
-
echo dsh-session-index
|
|
11
|
+
echo dsh-session-index updater (git pull + reinstall)
|
|
12
12
|
echo Dir: %PLUGIN_DIR%
|
|
13
13
|
echo ============================================================
|
|
14
14
|
echo.
|