shennian 0.2.64 → 0.2.66

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 (38) hide show
  1. package/dist/src/agent-env.js +2 -0
  2. package/dist/src/agents/command-spec.js +5 -0
  3. package/dist/src/agents/config-status.d.ts +5 -4
  4. package/dist/src/agents/config-status.js +30 -9
  5. package/dist/src/agents/manager.js +11 -1
  6. package/dist/src/agents/pi-context.d.ts +1 -1
  7. package/dist/src/agents/pi-context.js +4 -3
  8. package/dist/src/agents/pi.d.ts +1 -0
  9. package/dist/src/agents/pi.js +34 -5
  10. package/dist/src/commands/daemon.d.ts +7 -0
  11. package/dist/src/commands/daemon.js +28 -20
  12. package/dist/src/commands/external-attachments.d.ts +9 -0
  13. package/dist/src/commands/external-attachments.js +52 -0
  14. package/dist/src/commands/external.js +4 -52
  15. package/dist/src/commands/manager.js +49 -6
  16. package/dist/src/commands/tools.d.ts +2 -0
  17. package/dist/src/commands/tools.js +34 -0
  18. package/dist/src/commands/upgrade.js +1 -1
  19. package/dist/src/fs/boundary.js +7 -2
  20. package/dist/src/index.js +2 -0
  21. package/dist/src/manager/prompt.d.ts +1 -1
  22. package/dist/src/manager/prompt.js +10 -4
  23. package/dist/src/manager/registry.d.ts +4 -0
  24. package/dist/src/manager/registry.js +2 -0
  25. package/dist/src/manager/runtime.d.ts +8 -1
  26. package/dist/src/manager/runtime.js +35 -8
  27. package/dist/src/session/handlers/agent-config.js +3 -3
  28. package/dist/src/session/handlers/chat.js +33 -12
  29. package/dist/src/session/handlers/fs.d.ts +1 -0
  30. package/dist/src/session/handlers/fs.js +76 -2
  31. package/dist/src/session/manager.js +4 -1
  32. package/dist/src/session/queue.js +18 -2
  33. package/dist/src/session/remote-attachments.d.ts +15 -0
  34. package/dist/src/session/remote-attachments.js +72 -0
  35. package/dist/src/tools/markdown-to-pdf.d.ts +20 -0
  36. package/dist/src/tools/markdown-to-pdf.js +303 -0
  37. package/dist/src/upgrade/engine.js +5 -5
  38. package/package.json +2 -2
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { convertMarkdownToPdf, defaultPdfOutputPath } from '../../tools/markdown-to-pdf.js';
6
7
  const FILE_SYSTEM_ROOTS_PATH = '__roots__';
7
8
  const MAX_FOLDER_UPLOAD_FILES = 2000;
8
9
  const MAX_FOLDER_UPLOAD_TOTAL_SIZE = 1024 * 1024 * 1024;
@@ -23,6 +24,26 @@ function makeFsEntry(entryPath) {
23
24
  modifiedAt: stat.mtimeMs,
24
25
  };
25
26
  }
27
+ function listFileSystemRoots() {
28
+ if (os.platform() === 'win32') {
29
+ const entries = [];
30
+ for (let code = 65; code <= 90; code += 1) {
31
+ const drive = `${String.fromCharCode(code)}:\\`;
32
+ if (fs.existsSync(drive))
33
+ entries.push({ name: drive, path: drive, isDir: true });
34
+ }
35
+ return entries;
36
+ }
37
+ return [{ name: '/', path: '/', isDir: true }];
38
+ }
39
+ function fsErrorMessage(err, fallbackPath) {
40
+ const code = typeof err === 'object' && err && 'code' in err ? String(err.code) : '';
41
+ if (code === 'ENOENT')
42
+ return `Directory not found: ${fallbackPath}`;
43
+ if (code === 'EACCES' || code === 'EPERM')
44
+ return `Permission denied: ${fallbackPath}`;
45
+ return err instanceof Error ? err.message : String(err);
46
+ }
26
47
  function isSafeRelativeUploadPath(relativePath) {
27
48
  if (!relativePath || relativePath === '.' || relativePath.trim() !== relativePath)
28
49
  return false;
@@ -72,7 +93,12 @@ export async function handleFsLs(runtime, req) {
72
93
  const requestedPath = req.params.path || os.homedir();
73
94
  const rootPath = req.params.rootPath || requestedPath;
74
95
  if (requestedPath === FILE_SYSTEM_ROOTS_PATH) {
75
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Access denied: filesystem roots are outside the authorized directory' });
96
+ runtime.client.sendRes({
97
+ type: 'res',
98
+ id: req.id,
99
+ ok: true,
100
+ payload: { path: FILE_SYSTEM_ROOTS_PATH, entries: listFileSystemRoots() },
101
+ });
76
102
  return;
77
103
  }
78
104
  const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
@@ -103,7 +129,7 @@ export async function handleFsLs(runtime, req) {
103
129
  });
104
130
  }
105
131
  catch (err) {
106
- runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
132
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: fsErrorMessage(err, dirPath) });
107
133
  }
108
134
  }
109
135
  export async function handleFsRead(runtime, req) {
@@ -267,6 +293,54 @@ export async function handleFsRename(runtime, req) {
267
293
  runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: String(err) });
268
294
  }
269
295
  }
296
+ export async function handleFsExportMarkdownPdf(runtime, req) {
297
+ const requestedPath = req.params.path;
298
+ const rootPath = req.params.rootPath || path.dirname(requestedPath || '.');
299
+ const title = typeof req.params.title === 'string' ? req.params.title : undefined;
300
+ if (!requestedPath) {
301
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'path is required' });
302
+ return;
303
+ }
304
+ const resolved = runtime.resolveAuthorizedPath(requestedPath, rootPath);
305
+ if (!resolved.ok) {
306
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: resolved.error });
307
+ return;
308
+ }
309
+ if (!/\.mdx?$/i.test(resolved.path)) {
310
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'Only Markdown files can be exported to PDF' });
311
+ return;
312
+ }
313
+ const outputPath = defaultPdfOutputPath(resolved.path);
314
+ const checkedOutput = runtime.resolveAuthorizedPath(outputPath, rootPath);
315
+ if (!checkedOutput.ok) {
316
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: checkedOutput.error });
317
+ return;
318
+ }
319
+ try {
320
+ const result = await convertMarkdownToPdf(resolved.path, {
321
+ outputPath: checkedOutput.path,
322
+ title,
323
+ });
324
+ runtime.client.sendRes({
325
+ type: 'res',
326
+ id: req.id,
327
+ ok: true,
328
+ payload: {
329
+ sourcePath: resolved.path,
330
+ outputPath: result.outputPath,
331
+ entry: makeFsEntry(result.outputPath),
332
+ },
333
+ });
334
+ }
335
+ catch (err) {
336
+ runtime.client.sendRes({
337
+ type: 'res',
338
+ id: req.id,
339
+ ok: false,
340
+ error: err instanceof Error ? err.message : String(err),
341
+ });
342
+ }
343
+ }
270
344
  export async function handleFsTransfer(runtime, req) {
271
345
  const { name, targetPath, data, direct } = req.params;
272
346
  if (!name || !data) {
@@ -8,7 +8,7 @@ import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
8
8
  import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
9
9
  import { handleChatAbort, handleChatSend } from './handlers/chat.js';
10
10
  import { handleSessionRefresh } from './handlers/session-refresh.js';
11
- import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
11
+ import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, handleFsExportMarkdownPdf, } from './handlers/fs.js';
12
12
  import { handleSkillInstall, handleSkillList, handleSkillUse } from './handlers/skills.js';
13
13
  import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
14
14
  import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
@@ -117,6 +117,9 @@ export class SessionManager {
117
117
  case 'fs.write':
118
118
  await handleFsWrite(runtime, req);
119
119
  break;
120
+ case 'fs.export.markdown-pdf':
121
+ await handleFsExportMarkdownPdf(runtime, req);
122
+ break;
120
123
  case 'fs.rename':
121
124
  await handleFsRename(runtime, req);
122
125
  break;
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { resolveShennianPath } from '../config/index.js';
6
6
  import { mergeProjectedSessions } from './projection.js';
7
+ import { materializeRemoteChatAttachments } from './remote-attachments.js';
7
8
  const QUEUE_FILE = resolveShennianPath('chat-queue.json');
8
9
  function emptyQueue() {
9
10
  return { sessions: {} };
@@ -93,6 +94,10 @@ export class ChatQueueManager {
93
94
  });
94
95
  return;
95
96
  }
97
+ const normalizedAttachments = normalizeAttachments(params.attachments);
98
+ const materialized = normalizedAttachments?.length
99
+ ? await materializeRemoteChatAttachments({ text: params.text, attachments: normalizedAttachments, workDir: params.workDir })
100
+ : { text: params.text, attachments: normalizedAttachments, localized: false };
96
101
  const active = runtime.sessions.get(params.sessionId);
97
102
  const isBusy = Boolean(active?.currentRunId);
98
103
  if (!isBusy && !(readQueue().sessions[params.sessionId]?.length)) {
@@ -102,15 +107,21 @@ export class ChatQueueManager {
102
107
  method: 'chat.send',
103
108
  params: {
104
109
  ...params,
110
+ text: materialized.text,
105
111
  responseId: req.id,
106
112
  clientMessageId: params.clientMessageId ?? params.queueMessageId,
107
113
  waitForDispatch: true,
114
+ attachments: materialized.attachments,
108
115
  },
109
116
  });
110
117
  return;
111
118
  }
112
119
  const queue = readQueue();
113
- const message = queueMessageFromParams(params);
120
+ const message = queueMessageFromParams({
121
+ ...params,
122
+ text: materialized.text,
123
+ attachments: materialized.attachments,
124
+ });
114
125
  queue.sessions[params.sessionId] = [...(queue.sessions[params.sessionId] ?? []), message];
115
126
  writeQueue(queue);
116
127
  this.broadcast(params.sessionId);
@@ -118,7 +129,12 @@ export class ChatQueueManager {
118
129
  type: 'res',
119
130
  id: req.id,
120
131
  ok: true,
121
- payload: { queued: true, queueMessageId: message.id, queue: this.getSnapshot(params.sessionId) },
132
+ payload: {
133
+ queued: true,
134
+ queueMessageId: message.id,
135
+ queue: this.getSnapshot(params.sessionId),
136
+ ...(materialized.localized ? { localizedAttachments: true } : {}),
137
+ },
122
138
  });
123
139
  }
124
140
  async handleGet(req) {
@@ -0,0 +1,15 @@
1
+ export type ChatAttachmentInput = {
2
+ path: string;
3
+ name: string;
4
+ mimeType: string;
5
+ previewData?: string;
6
+ };
7
+ export declare function materializeRemoteChatAttachments(input: {
8
+ text: string;
9
+ attachments?: ChatAttachmentInput[];
10
+ workDir: string;
11
+ }): Promise<{
12
+ text: string;
13
+ attachments?: ChatAttachmentInput[];
14
+ localized: boolean;
15
+ }>;
@@ -0,0 +1,72 @@
1
+ // @arch docs/architecture/app/chat-attachments-preview.md
2
+ // @test src/__tests__/session-manager.test.ts
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ const MAX_REMOTE_ATTACHMENT_BYTES = Number(process.env.SHENNIAN_REMOTE_ATTACHMENT_MAX_BYTES || 50 * 1024 * 1024);
7
+ function safeFileName(name) {
8
+ const cleaned = path.basename(name || 'attachment')
9
+ .normalize('NFKC')
10
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
11
+ .replace(/[\r\n\t]+/g, ' ')
12
+ .replace(/\s+/g, ' ')
13
+ .replace(/_+/g, '_')
14
+ .replace(/^[ ._]+|[ ._]+$/g, '');
15
+ return cleaned || 'attachment';
16
+ }
17
+ function uniquePath(dir, name, hash) {
18
+ const safe = safeFileName(name);
19
+ const ext = path.extname(safe);
20
+ const stem = ext ? safe.slice(0, -ext.length) : safe;
21
+ const candidate = path.join(dir, safe);
22
+ if (!fs.existsSync(candidate))
23
+ return candidate;
24
+ return path.join(dir, `${stem}-${hash.slice(0, 8)}${ext}`);
25
+ }
26
+ function isHttpUrl(value) {
27
+ return /^https?:\/\//i.test(value);
28
+ }
29
+ async function downloadRemoteAttachment(attachment, workDir) {
30
+ if (!isHttpUrl(attachment.path))
31
+ return attachment;
32
+ const response = await fetch(attachment.path);
33
+ if (!response.ok)
34
+ return attachment;
35
+ const contentLength = Number(response.headers.get('content-length') || 0);
36
+ if (contentLength > MAX_REMOTE_ATTACHMENT_BYTES)
37
+ return attachment;
38
+ const buffer = Buffer.from(await response.arrayBuffer());
39
+ if (!buffer.byteLength || buffer.byteLength > MAX_REMOTE_ATTACHMENT_BYTES)
40
+ return attachment;
41
+ const hash = crypto.createHash('sha256').update(buffer).digest('hex');
42
+ const uploadDir = path.join(workDir, '.uploads');
43
+ fs.mkdirSync(uploadDir, { recursive: true });
44
+ const filePath = uniquePath(uploadDir, attachment.name, hash);
45
+ if (!fs.existsSync(filePath))
46
+ fs.writeFileSync(filePath, buffer);
47
+ return {
48
+ ...attachment,
49
+ path: filePath,
50
+ mimeType: attachment.mimeType || response.headers.get('content-type') || 'application/octet-stream',
51
+ };
52
+ }
53
+ function replaceAttachmentRefs(text, before, after) {
54
+ if (before.path === after.path || !before.path || !after.path)
55
+ return text;
56
+ return text.split(before.path).join(after.path);
57
+ }
58
+ export async function materializeRemoteChatAttachments(input) {
59
+ if (!input.attachments?.length)
60
+ return { text: input.text, attachments: input.attachments, localized: false };
61
+ const materialized = [];
62
+ let text = input.text;
63
+ let localized = false;
64
+ for (const attachment of input.attachments) {
65
+ const next = await downloadRemoteAttachment(attachment, input.workDir).catch(() => attachment);
66
+ if (next.path !== attachment.path && isHttpUrl(attachment.path))
67
+ localized = true;
68
+ text = replaceAttachmentRefs(text, attachment, next);
69
+ materialized.push(next);
70
+ }
71
+ return { text, attachments: materialized, localized };
72
+ }
@@ -0,0 +1,20 @@
1
+ export type MarkdownToPdfOptions = {
2
+ outputPath?: string;
3
+ title?: string;
4
+ keepHtml?: boolean;
5
+ chromePath?: string;
6
+ };
7
+ export type MarkdownToPdfResult = {
8
+ inputPath: string;
9
+ outputPath: string;
10
+ htmlPath: string | null;
11
+ browserPath: string;
12
+ };
13
+ export declare function defaultPdfOutputPath(inputPath: string): string;
14
+ export declare function renderMarkdownBody(markdown: string, sourceDir?: string): string;
15
+ export declare function renderMarkdownHtml(markdown: string, options?: {
16
+ title?: string;
17
+ sourceDir?: string;
18
+ }): string;
19
+ export declare function findChromiumExecutable(explicitPath?: string): Promise<string>;
20
+ export declare function convertMarkdownToPdf(inputPath: string, options?: MarkdownToPdfOptions): Promise<MarkdownToPdfResult>;
@@ -0,0 +1,303 @@
1
+ // @arch docs/features/markdown-pdf-export.md
2
+ // @test src/__tests__/markdown-to-pdf.test.ts
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { promisify } from 'node:util';
9
+ const execFileAsync = promisify(execFile);
10
+ const FENCED_CODE_RE = /^```(\S*)\s*$/;
11
+ function isWindowsAbsolutePath(value) {
12
+ return /^[A-Za-z]:([\\/]|$)/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value);
13
+ }
14
+ function pathApiForPath(value) {
15
+ return isWindowsAbsolutePath(value) ? path.win32 : path;
16
+ }
17
+ export function defaultPdfOutputPath(inputPath) {
18
+ const api = pathApiForPath(inputPath);
19
+ const parsed = api.parse(inputPath);
20
+ return api.join(parsed.dir, `${parsed.name}.pdf`);
21
+ }
22
+ function escapeHtml(value) {
23
+ return value
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ .replace(/'/g, '&#39;');
29
+ }
30
+ function slugify(value) {
31
+ return value
32
+ .toLowerCase()
33
+ .replace(/<[^>]+>/g, '')
34
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
35
+ .replace(/^-+|-+$/g, '');
36
+ }
37
+ function renderInline(markdown, sourceDir) {
38
+ let text = escapeHtml(markdown);
39
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
40
+ text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
41
+ text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
42
+ text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
43
+ text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
44
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, rawUrl) => {
45
+ const url = rawUrl.trim().replace(/^&lt;|&gt;$/g, '');
46
+ const src = toSafeAssetUrl(url, sourceDir);
47
+ return `<img src="${escapeHtml(src)}" alt="${alt}" />`;
48
+ });
49
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, rawUrl) => {
50
+ const url = rawUrl.trim().replace(/^&lt;|&gt;$/g, '');
51
+ return `<a href="${escapeHtml(url)}">${label}</a>`;
52
+ });
53
+ return text;
54
+ }
55
+ function toSafeAssetUrl(rawUrl, sourceDir) {
56
+ if (/^(https?:|data:|file:)/i.test(rawUrl))
57
+ return rawUrl;
58
+ const withoutAnchor = rawUrl.split('#')[0].split('?')[0];
59
+ const resolved = path.resolve(sourceDir, decodeURIComponent(withoutAnchor));
60
+ const relative = path.relative(sourceDir, resolved);
61
+ if (relative.startsWith('..') || path.isAbsolute(relative))
62
+ return '';
63
+ return pathToFileURL(resolved).href;
64
+ }
65
+ function renderTable(lines, sourceDir) {
66
+ if (lines.length < 2)
67
+ return null;
68
+ const separator = lines[1].trim();
69
+ if (!/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(separator))
70
+ return null;
71
+ const parseRow = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim());
72
+ const header = parseRow(lines[0]);
73
+ const rows = lines.slice(2).map(parseRow);
74
+ return [
75
+ '<table>',
76
+ '<thead><tr>',
77
+ ...header.map((cell) => `<th>${renderInline(cell, sourceDir)}</th>`),
78
+ '</tr></thead>',
79
+ '<tbody>',
80
+ ...rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, sourceDir)}</td>`).join('')}</tr>`),
81
+ '</tbody></table>',
82
+ ].join('');
83
+ }
84
+ export function renderMarkdownBody(markdown, sourceDir = process.cwd()) {
85
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
86
+ const out = [];
87
+ let paragraph = [];
88
+ let list = null;
89
+ let code = null;
90
+ const flushParagraph = () => {
91
+ if (paragraph.length === 0)
92
+ return;
93
+ out.push(`<p>${renderInline(paragraph.join(' '), sourceDir)}</p>`);
94
+ paragraph = [];
95
+ };
96
+ const flushList = () => {
97
+ if (!list)
98
+ return;
99
+ const tag = list.ordered ? 'ol' : 'ul';
100
+ out.push(`<${tag}>${list.items.map((item) => `<li>${renderInline(item, sourceDir)}</li>`).join('')}</${tag}>`);
101
+ list = null;
102
+ };
103
+ for (let i = 0; i < lines.length; i += 1) {
104
+ const line = lines[i];
105
+ const fence = line.match(FENCED_CODE_RE);
106
+ if (code) {
107
+ if (fence) {
108
+ out.push(`<pre><code${code.lang ? ` class="language-${escapeHtml(code.lang)}"` : ''}>${escapeHtml(code.lines.join('\n'))}</code></pre>`);
109
+ code = null;
110
+ }
111
+ else {
112
+ code.lines.push(line);
113
+ }
114
+ continue;
115
+ }
116
+ if (fence) {
117
+ flushParagraph();
118
+ flushList();
119
+ code = { lang: fence[1] || '', lines: [] };
120
+ continue;
121
+ }
122
+ if (!line.trim()) {
123
+ flushParagraph();
124
+ flushList();
125
+ continue;
126
+ }
127
+ const tableCandidate = lines.slice(i, i + 2);
128
+ const maybeTable = renderTable(tableCandidate, sourceDir);
129
+ if (maybeTable) {
130
+ const tableLines = [line, lines[i + 1]];
131
+ i += 2;
132
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
133
+ tableLines.push(lines[i]);
134
+ i += 1;
135
+ }
136
+ i -= 1;
137
+ flushParagraph();
138
+ flushList();
139
+ out.push(renderTable(tableLines, sourceDir));
140
+ continue;
141
+ }
142
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
143
+ if (heading) {
144
+ flushParagraph();
145
+ flushList();
146
+ const level = heading[1].length;
147
+ const content = renderInline(heading[2].trim(), sourceDir);
148
+ const id = slugify(content);
149
+ out.push(`<h${level}${id ? ` id="${id}"` : ''}>${content}</h${level}>`);
150
+ continue;
151
+ }
152
+ const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
153
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
154
+ if (unordered || ordered) {
155
+ flushParagraph();
156
+ const isOrdered = Boolean(ordered);
157
+ if (!list || list.ordered !== isOrdered)
158
+ flushList();
159
+ if (!list)
160
+ list = { ordered: isOrdered, items: [] };
161
+ list.items.push((unordered?.[1] ?? ordered?.[1] ?? '').trim());
162
+ continue;
163
+ }
164
+ const quote = line.match(/^>\s?(.+)$/);
165
+ if (quote) {
166
+ flushParagraph();
167
+ flushList();
168
+ out.push(`<blockquote>${renderInline(quote[1], sourceDir)}</blockquote>`);
169
+ continue;
170
+ }
171
+ if (/^---+$/.test(line.trim())) {
172
+ flushParagraph();
173
+ flushList();
174
+ out.push('<hr />');
175
+ continue;
176
+ }
177
+ paragraph.push(line.trim());
178
+ }
179
+ if (code) {
180
+ out.push(`<pre><code${code.lang ? ` class="language-${escapeHtml(code.lang)}"` : ''}>${escapeHtml(code.lines.join('\n'))}</code></pre>`);
181
+ }
182
+ flushParagraph();
183
+ flushList();
184
+ return out.join('\n');
185
+ }
186
+ export function renderMarkdownHtml(markdown, options = {}) {
187
+ const title = options.title || 'Markdown Export';
188
+ const body = renderMarkdownBody(markdown, options.sourceDir);
189
+ return `<!doctype html>
190
+ <html>
191
+ <head>
192
+ <meta charset="utf-8" />
193
+ <title>${escapeHtml(title)}</title>
194
+ <style>
195
+ @page { size: A4; margin: 18mm 16mm; }
196
+ * { box-sizing: border-box; }
197
+ body {
198
+ color: #111827;
199
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
200
+ font-size: 14px;
201
+ line-height: 1.68;
202
+ }
203
+ h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.4em 0 .55em; page-break-after: avoid; }
204
+ h1 { font-size: 28px; border-bottom: 1px solid #e5e7eb; padding-bottom: 10px; }
205
+ h2 { font-size: 22px; border-bottom: 1px solid #eef2f7; padding-bottom: 6px; }
206
+ h3 { font-size: 18px; }
207
+ p { margin: .65em 0; }
208
+ a { color: #2563eb; text-decoration: none; }
209
+ img { display: block; max-width: 100%; max-height: 240mm; margin: 12px auto; object-fit: contain; }
210
+ pre { background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; overflow: hidden; white-space: pre-wrap; word-break: break-word; }
211
+ code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: .92em; }
212
+ p code, li code, td code { background: #f3f4f6; border-radius: 4px; padding: 1px 4px; }
213
+ blockquote { margin: 1em 0; padding: .6em 1em; border-left: 4px solid #d1d5db; color: #4b5563; background: #f9fafb; }
214
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 13px; }
215
+ th, td { border: 1px solid #d1d5db; padding: 7px 9px; vertical-align: top; }
216
+ th { background: #f3f4f6; font-weight: 700; }
217
+ tr { page-break-inside: avoid; }
218
+ hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.6em 0; }
219
+ </style>
220
+ </head>
221
+ <body>
222
+ ${body}
223
+ </body>
224
+ </html>`;
225
+ }
226
+ function executableCandidates() {
227
+ const envPath = process.env.SHENNIAN_CHROME_PATH;
228
+ const candidates = envPath ? [envPath] : [];
229
+ if (process.platform === 'darwin') {
230
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser');
231
+ }
232
+ else if (process.platform === 'win32') {
233
+ const roots = [
234
+ process.env.PROGRAMFILES,
235
+ process.env['PROGRAMFILES(X86)'],
236
+ process.env.LOCALAPPDATA,
237
+ ].filter((value) => Boolean(value));
238
+ for (const root of roots) {
239
+ candidates.push(path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
240
+ }
241
+ }
242
+ else {
243
+ candidates.push('google-chrome-stable', 'google-chrome', 'chromium-browser', 'chromium', 'microsoft-edge', 'brave-browser');
244
+ }
245
+ return candidates;
246
+ }
247
+ export async function findChromiumExecutable(explicitPath) {
248
+ const candidates = explicitPath ? [explicitPath] : executableCandidates();
249
+ for (const candidate of candidates) {
250
+ if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
251
+ if (fs.existsSync(candidate))
252
+ return candidate;
253
+ continue;
254
+ }
255
+ try {
256
+ await execFileAsync(candidate, ['--version'], { timeout: 5000 });
257
+ return candidate;
258
+ }
259
+ catch {
260
+ // continue
261
+ }
262
+ }
263
+ throw new Error('No Chrome/Edge/Chromium executable found. Install Chrome/Edge/Chromium or set SHENNIAN_CHROME_PATH.');
264
+ }
265
+ export async function convertMarkdownToPdf(inputPath, options = {}) {
266
+ const resolvedInput = path.resolve(inputPath);
267
+ const stat = fs.statSync(resolvedInput);
268
+ if (!stat.isFile())
269
+ throw new Error(`Not a file: ${inputPath}`);
270
+ if (!/\.mdx?$/i.test(resolvedInput))
271
+ throw new Error('Input must be a Markdown file (.md or .mdx)');
272
+ const outputPath = path.resolve(options.outputPath || defaultPdfOutputPath(resolvedInput));
273
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
274
+ const markdown = fs.readFileSync(resolvedInput, 'utf8');
275
+ const html = renderMarkdownHtml(markdown, {
276
+ title: options.title || path.basename(resolvedInput),
277
+ sourceDir: path.dirname(resolvedInput),
278
+ });
279
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shennian-mdpdf-'));
280
+ const htmlPath = options.keepHtml ? outputPath.replace(/\.pdf$/i, '.html') : path.join(tempDir, 'document.html');
281
+ fs.writeFileSync(htmlPath, html, 'utf8');
282
+ const browserPath = await findChromiumExecutable(options.chromePath);
283
+ const args = [
284
+ '--headless=new',
285
+ '--disable-gpu',
286
+ '--no-sandbox',
287
+ '--disable-dev-shm-usage',
288
+ '--allow-file-access-from-files',
289
+ `--print-to-pdf=${outputPath}`,
290
+ pathToFileURL(htmlPath).href,
291
+ ];
292
+ try {
293
+ await execFileAsync(browserPath, args, { timeout: 120_000, maxBuffer: 1024 * 1024 });
294
+ }
295
+ finally {
296
+ if (!options.keepHtml)
297
+ fs.rmSync(tempDir, { recursive: true, force: true });
298
+ }
299
+ if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
300
+ throw new Error('PDF export failed: output file was not created');
301
+ }
302
+ return { inputPath: resolvedInput, outputPath, htmlPath: options.keepHtml ? htmlPath : null, browserPath };
303
+ }
@@ -73,7 +73,7 @@ export function compareVersions(current, latest) {
73
73
  }
74
74
  // ─── npm global path helpers ──────────────────────────────────────────────────
75
75
  function getNpmGlobalRoot() {
76
- return execSync('npm root -g', { encoding: 'utf-8', stdio: 'pipe' }).trim();
76
+ return execSync('npm root -g', { encoding: 'utf-8', stdio: 'pipe', windowsHide: true }).trim();
77
77
  }
78
78
  function getGlobalPkgDir() {
79
79
  return path.join(getNpmGlobalRoot(), 'shennian');
@@ -222,7 +222,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
222
222
  // Step 1: Verify npm is accessible
223
223
  onProgress({ step: 'checking' });
224
224
  try {
225
- execSync('npm --version', { stdio: 'pipe' });
225
+ execSync('npm --version', { stdio: 'pipe', windowsHide: true });
226
226
  }
227
227
  catch {
228
228
  return { ok: false, error: 'npm is not available in PATH' };
@@ -231,7 +231,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
231
231
  onProgress({ step: 'verifying', version: targetVersion });
232
232
  try {
233
233
  const binScript = getGlobalBinScript();
234
- const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000 });
234
+ const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000, windowsHide: true });
235
235
  if (!stdout.trim())
236
236
  throw new Error('Empty output from --version check');
237
237
  }
@@ -263,7 +263,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
263
263
  // Step 3: npm install new version
264
264
  onProgress({ step: 'installing', version: targetVersion });
265
265
  try {
266
- await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000 });
266
+ await exec(`npm install -g shennian@${targetVersion}`, { timeout: 120_000, windowsHide: true });
267
267
  }
268
268
  catch (err) {
269
269
  // Restore backup and abort
@@ -281,7 +281,7 @@ export async function performUpgrade(targetVersion, onProgress, opts = {}) {
281
281
  onProgress({ step: 'verifying', version: targetVersion });
282
282
  try {
283
283
  const binScript = getGlobalBinScript();
284
- const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000 });
284
+ const { stdout } = await exec(`node "${binScript}" --version`, { timeout: 10_000, windowsHide: true });
285
285
  if (!stdout.trim())
286
286
  throw new Error('Empty output from --version check');
287
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.64",
3
+ "version": "0.2.66",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "commander": "^13.1.0",
41
41
  "qrcode-terminal": "^0.12.0",
42
42
  "ws": "^8.18.1",
43
- "@shennian/wire": "0.1.4"
43
+ "@shennian/wire": "0.1.5"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^20",