dsh-caduceus 0.7.0 → 0.10.5

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.
@@ -4,14 +4,14 @@
4
4
  *
5
5
  * The surface owns eight commands; every other slash line is forwarded to the
6
6
  * harness command registry (`runHarnessCommand`), except Web-only commands that
7
- * make no sense on a chat surface. `/export` is the required minimum; the list
8
- * is intentionally small and explicit.
7
+ * make no sense on a chat surface. `/export` is now implemented by the surface
8
+ * itself, so the list is intentionally empty; keep the seam for future entries.
9
9
  *
10
10
  * @module dsh-caduceus/commands/dispatch
11
11
  */
12
12
  import { parseCommand } from './parse.js';
13
13
  /** Commands that exist only in the Web UI and are refused on Feishu. */
14
- export const WEB_ONLY_DENYLIST = ['export'];
14
+ export const WEB_ONLY_DENYLIST = [];
15
15
  /** True when a slash-command name must not be forwarded to the harness. */
16
16
  export function isWebOnlyCommand(name) {
17
17
  return WEB_ONLY_DENYLIST.includes(name.toLowerCase());
@@ -1,5 +1,5 @@
1
1
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
2
- export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "whoami", "help"];
2
+ export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "whoami", "export", "permission", "usage", "help"];
3
3
  export type CommandName = (typeof COMMAND_NAMES)[number];
4
4
  export interface CommandSpec {
5
5
  readonly name: CommandName;
@@ -38,6 +38,14 @@ export type ParsedCommand = {
38
38
  readonly action: PersonaAction;
39
39
  } | {
40
40
  readonly kind: 'whoami';
41
+ } | {
42
+ readonly kind: 'export';
43
+ readonly json?: boolean;
44
+ } | {
45
+ readonly kind: 'permission';
46
+ readonly preset?: string;
47
+ } | {
48
+ readonly kind: 'usage-report';
41
49
  } | {
42
50
  readonly kind: 'help';
43
51
  } | {
@@ -57,5 +65,10 @@ export type ParsedCommand = {
57
65
  * never rewrites a user's text.
58
66
  */
59
67
  export declare function parseCommand(raw: string): ParsedCommand;
60
- /** The reply body for `/help`, one line per owned command. */
68
+ /** dsh commands that already pass through the harness registry. */
69
+ export declare const DSH_NATIVE_COMMANDS: readonly {
70
+ readonly name: string;
71
+ readonly description: string;
72
+ }[];
73
+ /** The reply body for `/help`: owned commands plus dsh native passthroughs. */
61
74
  export declare function helpText(): string;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { stripLeadingMentions } from '../mentions.js';
13
13
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
14
- export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'whoami', 'help'];
14
+ export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'whoami', 'export', 'permission', 'usage', 'help'];
15
15
  /** Command metadata; `helpText()` and the README command table both derive from it. */
16
16
  export const COMMAND_SPECS = [
17
17
  { name: 'new', usage: '/new', description: '为当前聊天/话题新建会话,旧会话保留' },
@@ -25,6 +25,9 @@ export const COMMAND_SPECS = [
25
25
  { name: 'status', usage: '/status', description: '显示本会话的运行状态、步数与起止时间' },
26
26
  { name: 'persona', usage: '/persona [show|sync]', description: '查看或同步 SOUL 人格块' },
27
27
  { name: 'whoami', usage: '/whoami', description: '显示你的 open_id、当前会话与身份' },
28
+ { name: 'export', usage: '/export [--json]', description: '把当前会话导出为 markdown 或 JSONL 并发回文件' },
29
+ { name: 'permission', usage: '/permission [档位]', description: '查看或切换当前会话的权限档位(中文)' },
30
+ { name: 'usage', usage: '/usage', description: '查看本会话 token 用量(本回合/上回合/合计)' },
28
31
  { name: 'help', usage: '/help', description: '显示命令表' },
29
32
  ];
30
33
  function usageResult(command, reason) {
@@ -66,6 +69,23 @@ export function parseCommand(raw) {
66
69
  return rest === '' ? { kind: 'status' } : usageResult('status', '/status 不接受参数');
67
70
  case 'whoami':
68
71
  return rest === '' ? { kind: 'whoami' } : usageResult('whoami', '/whoami 不接受参数');
72
+ case 'export': {
73
+ if (rest === '')
74
+ return { kind: 'export' };
75
+ if (rest === '--json')
76
+ return { kind: 'export', json: true };
77
+ return usageResult('export', '用法:/export [--json]');
78
+ }
79
+ case 'usage':
80
+ return rest === '' ? { kind: 'usage-report' } : usageResult('usage', '/usage 不接受参数');
81
+ case 'permission': {
82
+ if (rest === '')
83
+ return { kind: 'permission' };
84
+ const parts = rest.split(/\s+/);
85
+ if (parts.length > 1)
86
+ return usageResult('permission', '用法:/permission [档位]');
87
+ return { kind: 'permission', preset: parts[0] };
88
+ }
69
89
  case 'help':
70
90
  return { kind: 'help' };
71
91
  case 'rename':
@@ -108,11 +128,22 @@ export function parseCommand(raw) {
108
128
  return { kind: 'passthrough', raw };
109
129
  }
110
130
  }
111
- /** The reply body for `/help`, one line per owned command. */
131
+ /** dsh commands that already pass through the harness registry. */
132
+ export const DSH_NATIVE_COMMANDS = [
133
+ { name: 'compact', description: '压缩当前会话上下文' },
134
+ { name: 'goal', description: '查看/管理当前目标' },
135
+ { name: 'plan', description: '查看/切换计划模式' },
136
+ { name: 'feedback', description: '提交反馈' },
137
+ ];
138
+ /** The reply body for `/help`: owned commands plus dsh native passthroughs. */
112
139
  export function helpText() {
113
140
  const lines = ['dsh-caduceus 命令:'];
114
141
  for (const spec of COMMAND_SPECS)
115
142
  lines.push(`${spec.usage} — ${spec.description}`);
116
- lines.push('其他 /命令 与普通消息原样交给 dsh。');
143
+ lines.push('', 'dsh 原生命令(透传):');
144
+ for (const command of DSH_NATIVE_COMMANDS) {
145
+ lines.push(`/${command.name} — ${command.description}`);
146
+ }
147
+ lines.push('', '其他 /命令 与普通消息原样交给 dsh。');
117
148
  return lines.join('\n');
118
149
  }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import type { ApprovalPolicy, BridgeConfig, BusyMode } from './bridge/types.js';
3
+ import type { LangSetting } from './i18n.js';
4
+ import type { MetricsMode } from './metrics.js';
3
5
  /** Raw entry config as written in the profile. */
4
6
  export interface GatewayConfig {
5
7
  readonly appId?: string;
@@ -23,7 +25,7 @@ export interface GatewayConfig {
23
25
  readonly questionCard?: boolean;
24
26
  /** Interactive card timeout in ms (default 300000). */
25
27
  readonly interactiveTimeoutMs?: number;
26
- /** Session approval policy; IM humans are present, so default `ask`. */
28
+ /** Session approval policy override; undefined keeps dsh's own policy. */
27
29
  readonly approvalPolicy?: ApprovalPolicy;
28
30
  /** Emoji reactions on inbound/turn-end (default true). */
29
31
  readonly reactions?: boolean;
@@ -51,6 +53,12 @@ export interface GatewayConfig {
51
53
  readonly mediaRoots?: readonly string[];
52
54
  /** Admin open-id allowlist; empty disables access control (default). */
53
55
  readonly admins?: readonly string[];
56
+ /** UI language: zh (default), en, or auto (settings/env, fallback zh). */
57
+ readonly lang?: LangSetting;
58
+ /** Export cache retention in hours (default 24). */
59
+ readonly exportCacheMaxAgeHours?: number;
60
+ /** Process-card/usage metrics mode (default compact). */
61
+ readonly metrics?: MetricsMode;
54
62
  }
55
63
  /** Schemastery schema for the `caduceus-gateway` entry. */
56
64
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
@@ -82,6 +90,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
82
90
  maxSendImageBytes: z<number, number, "plain">;
83
91
  mediaRoots: z<string[], string[], "plain">;
84
92
  admins: z<string[], string[], "plain">;
93
+ lang: z<"zh" | "en" | "auto", "zh" | "en" | "auto", "plain">;
94
+ exportCacheMaxAgeHours: z<number, number, "plain">;
95
+ metrics: z<"off" | "compact" | "detailed", "off" | "compact" | "detailed", "plain">;
85
96
  }>>, Schemastery.ObjectT<NoInfer<{
86
97
  appId: z<string, string, "plain">;
87
98
  appSecret: z<string, string, "plain">;
@@ -111,6 +122,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
111
122
  maxSendImageBytes: z<number, number, "plain">;
112
123
  mediaRoots: z<string[], string[], "plain">;
113
124
  admins: z<string[], string[], "plain">;
125
+ lang: z<"zh" | "en" | "auto", "zh" | "en" | "auto", "plain">;
126
+ exportCacheMaxAgeHours: z<number, number, "plain">;
127
+ metrics: z<"off" | "compact" | "detailed", "off" | "compact" | "detailed", "plain">;
114
128
  }>>, "plain">;
115
129
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
116
130
  export declare const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -137,8 +151,6 @@ export declare const DEFAULT_REACTIONS = true;
137
151
  export declare const DEFAULT_REACTION_ON_START = "OnIt";
138
152
  export declare const DEFAULT_REACTION_ON_DONE = "DONE";
139
153
  export declare const DEFAULT_REACTION_ON_FAIL = "CROSS_MARK";
140
- /** Default session approval policy: ask, so dangerous actions prompt a card. */
141
- export declare const DEFAULT_APPROVAL_POLICY: ApprovalPolicy;
142
154
  /** Default interactive-card switches/timeout. */
143
155
  export declare const DEFAULT_APPROVAL_CARD = true;
144
156
  export declare const DEFAULT_QUESTION_CARD = true;
@@ -160,7 +172,9 @@ export interface ResolvedGatewayConfig {
160
172
  readonly approvalCard: boolean;
161
173
  readonly questionCard: boolean;
162
174
  readonly interactiveTimeoutMs: number;
163
- readonly approvalPolicy: ApprovalPolicy;
175
+ readonly approvalPolicy?: ApprovalPolicy;
176
+ readonly lang: LangSetting;
177
+ readonly exportCacheMaxAgeHours: number;
164
178
  }
165
179
  export interface ConfigEnv {
166
180
  readonly FEISHU_APP_ID?: string;
package/dist/config.js CHANGED
@@ -40,6 +40,9 @@ export const Config = z.object({
40
40
  maxSendImageBytes: z.natural().min(1).required(false),
41
41
  mediaRoots: z.array(z.string()).required(false),
42
42
  admins: z.array(z.string()).required(false),
43
+ lang: z.union([z.const('zh'), z.const('en'), z.const('auto')]).required(false),
44
+ exportCacheMaxAgeHours: z.natural().min(1).required(false),
45
+ metrics: z.union([z.const('off'), z.const('compact'), z.const('detailed')]).required(false),
43
46
  });
44
47
  /** Default card patch throttle: half of Feishu's 5 QPS budget. */
45
48
  export const DEFAULT_CARD_THROTTLE_MS = 400;
@@ -66,8 +69,6 @@ export const DEFAULT_REACTIONS = true;
66
69
  export const DEFAULT_REACTION_ON_START = 'OnIt';
67
70
  export const DEFAULT_REACTION_ON_DONE = 'DONE';
68
71
  export const DEFAULT_REACTION_ON_FAIL = 'CROSS_MARK';
69
- /** Default session approval policy: ask, so dangerous actions prompt a card. */
70
- export const DEFAULT_APPROVAL_POLICY = 'ask';
71
72
  /** Default interactive-card switches/timeout. */
72
73
  export const DEFAULT_APPROVAL_CARD = true;
73
74
  export const DEFAULT_QUESTION_CARD = true;
@@ -117,6 +118,7 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
117
118
  maxSendImageBytes: config.maxSendImageBytes ?? DEFAULT_MAX_SEND_IMAGE_BYTES,
118
119
  mediaRoots: config.mediaRoots ?? [],
119
120
  admins: config.admins ?? [],
121
+ metrics: config.metrics ?? 'compact',
120
122
  };
121
123
  return {
122
124
  ...(appId === undefined || appSecret === undefined ? {} : { credentials: { appId, appSecret } }),
@@ -127,6 +129,10 @@ export function resolveGatewayConfig(config = {}, env = process.env, cwd = proce
127
129
  approvalCard: config.approvalCard ?? DEFAULT_APPROVAL_CARD,
128
130
  questionCard: config.questionCard ?? DEFAULT_QUESTION_CARD,
129
131
  interactiveTimeoutMs: config.interactiveTimeoutMs ?? DEFAULT_INTERACTIVE_TIMEOUT_MS,
130
- approvalPolicy: config.approvalPolicy ?? DEFAULT_APPROVAL_POLICY,
132
+ ...(config.approvalPolicy === undefined
133
+ ? {}
134
+ : { approvalPolicy: config.approvalPolicy }),
135
+ lang: config.lang ?? 'zh',
136
+ exportCacheMaxAgeHours: config.exportCacheMaxAgeHours ?? 24,
131
137
  };
132
138
  }
@@ -0,0 +1,44 @@
1
+ import type { LoggerLike } from './logger.js';
2
+ export type ExportRole = 'user' | 'assistant' | 'tool';
3
+ export interface ExportMessage {
4
+ readonly role: ExportRole;
5
+ readonly text: string;
6
+ readonly toolName?: string;
7
+ }
8
+ export interface SessionExportData {
9
+ readonly messages: readonly ExportMessage[];
10
+ /** Original uncompressed JSONL when available. */
11
+ readonly rawJsonl?: string;
12
+ }
13
+ export type SessionExportProvider = (sessionId: string) => SessionExportData | undefined | Promise<SessionExportData | undefined>;
14
+ export declare const EXPORT_MAX_BYTES: number;
15
+ export declare function truncateExport(text: string): string;
16
+ export declare function parseSessionEvents(events: readonly unknown[]): ExportMessage[];
17
+ /** Locate the newest `session[.vN].jsonl[.zstd]` belonging to one session. */
18
+ export declare function findSessionLog(sessionId: string, dshHome: string): string | undefined;
19
+ /**
20
+ * Read a session log directly from `$DSH_HOME/sessions`.
21
+ *
22
+ * @throws Error with a user-readable message when the log or zstd support is missing.
23
+ */
24
+ export declare function readSessionLogFile(sessionId: string, dshHome?: string): SessionExportData | undefined;
25
+ /** Markdown export with a bounded file size. */
26
+ export declare function renderExportMarkdown(input: {
27
+ readonly sessionId: string;
28
+ readonly title?: string;
29
+ readonly now: number;
30
+ readonly messages: readonly ExportMessage[];
31
+ }): string;
32
+ /** Raw JSONL export (one normalized message per line, bounded). */
33
+ export declare function renderExportJsonl(messages: readonly ExportMessage[]): string;
34
+ /** Write one export file into the cache directory; returns the absolute path. */
35
+ export declare function writeExportFile(directory: string, scopeKind: string, sessionId: string, extension: 'md' | 'jsonl', content: string, now: number): string;
36
+ export interface ExportCacheCleanupResult {
37
+ readonly removed: number;
38
+ readonly kept: number;
39
+ }
40
+ /**
41
+ * Remove export-cache files older than `maxAgeHours`. A missing directory is
42
+ * fine; any filesystem error is logged as one warn and never thrown.
43
+ */
44
+ export declare function cleanupExportCache(directory: string, maxAgeHours: number, now?: number, logger?: LoggerLike): ExportCacheCleanupResult;
package/dist/export.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `/export`: render one dsh session as markdown (or raw JSONL) for Feishu.
3
+ *
4
+ * The Web UI exports ZIPs with a browser-side download; IM must do its own
5
+ * rendering, so this module reads the session log (provided by a service seam or
6
+ * the on-disk JSONL/Zstd log) and writes a bounded local file that the MEDIA
7
+ * chain can send.
8
+ *
9
+ * @module dsh-caduceus/export
10
+ */
11
+ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { basename, join, sep } from 'node:path';
14
+ import { zstdDecompressSync } from 'node:zlib';
15
+ export const EXPORT_MAX_BYTES = 5 * 1024 * 1024;
16
+ const TRUNCATED_MARKER = '\n\n> 导出内容超过 5MB,已截断。\n';
17
+ export function truncateExport(text) {
18
+ return truncateUtf8(text, TRUNCATED_MARKER);
19
+ }
20
+ function truncateUtf8(text, marker) {
21
+ if (Buffer.byteLength(text, 'utf8') <= EXPORT_MAX_BYTES)
22
+ return text;
23
+ const budget = EXPORT_MAX_BYTES - Buffer.byteLength(marker, 'utf8');
24
+ return Buffer.from(text, 'utf8').subarray(0, Math.max(0, budget)).toString('utf8') + marker;
25
+ }
26
+ function textFromContent(content) {
27
+ if (typeof content === 'string')
28
+ return content;
29
+ if (!Array.isArray(content))
30
+ return '';
31
+ const parts = [];
32
+ for (const block of content) {
33
+ if (typeof block !== 'object' || block === null)
34
+ continue;
35
+ const record = block;
36
+ if (record.type === 'text' && typeof record.text === 'string')
37
+ parts.push(record.text);
38
+ }
39
+ return parts.join('\n');
40
+ }
41
+ export function parseSessionEvents(events) {
42
+ return messagesFromJsonl(events.map((event) => JSON.stringify(event)).join('\n'));
43
+ }
44
+ function messagesFromJsonl(raw) {
45
+ const messages = [];
46
+ for (const line of raw.split('\n')) {
47
+ if (line.trim().length === 0)
48
+ continue;
49
+ let event;
50
+ try {
51
+ event = JSON.parse(line);
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ if (typeof event !== 'object' || event === null)
57
+ continue;
58
+ const record = event;
59
+ const type = typeof record.type === 'string' ? record.type : '';
60
+ const data = (record.data ?? {});
61
+ const message = (data['message'] ?? {});
62
+ const text = textFromContent(message['content'] ?? data['content']);
63
+ if (text.length === 0)
64
+ continue;
65
+ if (type === 'user/message') {
66
+ messages.push({ role: 'user', text });
67
+ }
68
+ else if (type === 'assistant/message') {
69
+ messages.push({ role: 'assistant', text });
70
+ }
71
+ else if (type === 'tool/result') {
72
+ const toolName = (typeof data['toolName'] === 'string' && data['toolName']) ||
73
+ (typeof data['name'] === 'string' && data['name']) ||
74
+ (typeof message['name'] === 'string' && message['name']) ||
75
+ 'tool';
76
+ messages.push({ role: 'tool', toolName, text });
77
+ }
78
+ }
79
+ return messages;
80
+ }
81
+ /** Locate the newest `session[.vN].jsonl[.zstd]` belonging to one session. */
82
+ export function findSessionLog(sessionId, dshHome) {
83
+ const root = join(dshHome, 'sessions');
84
+ const candidates = [];
85
+ const walk = (dir, depth) => {
86
+ if (depth > 8)
87
+ return;
88
+ let entries;
89
+ try {
90
+ entries = readdirSync(dir, { withFileTypes: true });
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ for (const entry of entries) {
96
+ const path = join(dir, entry.name);
97
+ if (entry.isDirectory()) {
98
+ walk(path, depth + 1);
99
+ continue;
100
+ }
101
+ if (!/^session(?:\.[^/]+)?\.jsonl(?:\.zstd)?$/.test(entry.name))
102
+ continue;
103
+ if (!path.split(sep).includes(sessionId))
104
+ continue;
105
+ try {
106
+ candidates.push({ path, mtime: statSync(path).mtimeMs });
107
+ }
108
+ catch {
109
+ // ignore unreadable files
110
+ }
111
+ }
112
+ };
113
+ walk(root, 0);
114
+ return candidates.sort((left, right) => right.mtime - left.mtime)[0]?.path;
115
+ }
116
+ /**
117
+ * Read a session log directly from `$DSH_HOME/sessions`.
118
+ *
119
+ * @throws Error with a user-readable message when the log or zstd support is missing.
120
+ */
121
+ export function readSessionLogFile(sessionId, dshHome = process.env['DSH_HOME'] ?? join(homedir(), '.dsh')) {
122
+ const path = findSessionLog(sessionId, dshHome);
123
+ if (path === undefined)
124
+ return undefined;
125
+ const bytes = readFileSync(path);
126
+ let raw;
127
+ if (path.endsWith('.zstd')) {
128
+ const decompress = zstdDecompressSync;
129
+ if (typeof decompress !== 'function') {
130
+ throw new Error('当前 Node.js 不支持 zstd 解压,无法读取会话日志');
131
+ }
132
+ raw = decompress(bytes).toString('utf8');
133
+ }
134
+ else {
135
+ raw = bytes.toString('utf8');
136
+ }
137
+ return { messages: messagesFromJsonl(raw), rawJsonl: raw };
138
+ }
139
+ /** Markdown export with a bounded file size. */
140
+ export function renderExportMarkdown(input) {
141
+ const lines = [
142
+ '# dsh 会话导出',
143
+ '',
144
+ `- sessionId: ${input.sessionId}`,
145
+ `- 标题: ${input.title ?? '(无标题)'}`,
146
+ `- 导出时间: ${new Date(input.now).toISOString()}`,
147
+ `- 消息数: ${input.messages.length}`,
148
+ '',
149
+ ];
150
+ for (const message of input.messages) {
151
+ lines.push(`## ${message.role}${message.toolName === undefined ? '' : `: ${message.toolName}`}`);
152
+ lines.push('```');
153
+ lines.push(message.text);
154
+ lines.push('```');
155
+ lines.push('');
156
+ }
157
+ return truncateUtf8(lines.join('\n'), TRUNCATED_MARKER);
158
+ }
159
+ /** Raw JSONL export (one normalized message per line, bounded). */
160
+ export function renderExportJsonl(messages) {
161
+ const raw = messages.map((message) => JSON.stringify(message)).join('\n') + '\n';
162
+ return truncateUtf8(raw, TRUNCATED_MARKER.trim());
163
+ }
164
+ /** Timestamp used in readable export filenames. */
165
+ function exportStamp(now) {
166
+ const date = new Date(now);
167
+ const pad = (value) => String(value).padStart(2, '0');
168
+ return (String(date.getFullYear()) +
169
+ pad(date.getMonth() + 1) +
170
+ pad(date.getDate()) +
171
+ '-' +
172
+ pad(date.getHours()) +
173
+ pad(date.getMinutes()) +
174
+ pad(date.getSeconds()));
175
+ }
176
+ /** Write one export file into the cache directory; returns the absolute path. */
177
+ export function writeExportFile(directory, scopeKind, sessionId, extension, content, now) {
178
+ mkdirSync(directory, { recursive: true });
179
+ const kind = scopeKind.replace(/[^a-z0-9-]+/gi, '-') || 'chat';
180
+ const target = join(directory, `caduceus-${kind}-${sessionId.slice(-8)}-${exportStamp(now)}.${extension}`);
181
+ writeFileSync(target, content);
182
+ return target;
183
+ }
184
+ /**
185
+ * Remove export-cache files older than `maxAgeHours`. A missing directory is
186
+ * fine; any filesystem error is logged as one warn and never thrown.
187
+ */
188
+ export function cleanupExportCache(directory, maxAgeHours, now = Date.now(), logger) {
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(directory, { withFileTypes: true });
192
+ }
193
+ catch (error) {
194
+ if (error.code !== 'ENOENT') {
195
+ logger?.warn(`export cache cleanup failed for ${directory}: ${error instanceof Error ? error.message : String(error)}`);
196
+ }
197
+ return { removed: 0, kept: 0 };
198
+ }
199
+ const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
200
+ let removed = 0;
201
+ let kept = 0;
202
+ for (const entry of entries) {
203
+ if (!entry.isFile() || !entry.name.startsWith('caduceus-'))
204
+ continue;
205
+ const path = join(directory, entry.name);
206
+ try {
207
+ const age = now - statSync(path).mtimeMs;
208
+ if (age > maxAgeMs) {
209
+ unlinkSync(path);
210
+ removed += 1;
211
+ }
212
+ else {
213
+ kept += 1;
214
+ }
215
+ }
216
+ catch (error) {
217
+ logger?.warn(`export cache cleanup skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
218
+ }
219
+ }
220
+ logger?.info(`export cache: removed=${removed} kept=${kept}`);
221
+ return { removed, kept };
222
+ }
package/dist/home.d.ts CHANGED
@@ -34,5 +34,7 @@ export declare function defaultSoulCandidatePaths(env?: HomeEnv): readonly strin
34
34
  export declare function defaultAgentsFile(env?: HomeEnv): string;
35
35
  /** Legacy target written by older caduceus builds: `<DSH_AGENTS_HOME>/AGENTS.md`. */
36
36
  export declare function legacyAgentsFile(env?: HomeEnv): string;
37
+ /** Export cache directory: `<DSH_HOME>/caduceus/cache/exports`. */
38
+ export declare function defaultExportCacheDir(env?: HomeEnv): string;
37
39
  /** Default durable log file: `<DSH_HOME>/caduceus/caduceus.log`. */
38
40
  export declare function defaultLogPath(env?: HomeEnv): string;
package/dist/home.js CHANGED
@@ -62,6 +62,10 @@ export function defaultAgentsFile(env) {
62
62
  export function legacyAgentsFile(env) {
63
63
  return join(agentsHome(env), 'AGENTS.md');
64
64
  }
65
+ /** Export cache directory: `<DSH_HOME>/caduceus/cache/exports`. */
66
+ export function defaultExportCacheDir(env) {
67
+ return join(dshHome(env), 'caduceus', 'cache', 'exports');
68
+ }
65
69
  /** Default durable log file: `<DSH_HOME>/caduceus/caduceus.log`. */
66
70
  export function defaultLogPath(env) {
67
71
  return join(dshHome(env), 'caduceus', 'caduceus.log');
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Surface i18n for the permission UI.
3
+ *
4
+ * Language order:
5
+ * 1. explicit plugin config `lang: zh|en` (never consults settings/env);
6
+ * 2. `lang: auto` -> host `locale.preference`;
7
+ * 3. `lang: auto` -> LC_ALL/LANG, accepted only when the value starts with
8
+ * `zh` or `en` (C/C.UTF-8/POSIX/empty are ignored);
9
+ * 4. zh.
10
+ *
11
+ * Settings access is best-effort and never cached: a missing service must not
12
+ * break or stick on a failure.
13
+ *
14
+ * @module dsh-caduceus/i18n
15
+ */
16
+ export type Lang = 'zh' | 'en';
17
+ export type LangSetting = Lang | 'auto';
18
+ export interface PermissionLineData {
19
+ readonly name: string;
20
+ readonly sandbox?: string;
21
+ readonly approval?: string;
22
+ }
23
+ export interface Dict {
24
+ permissionTitle(currentLabel: string): string;
25
+ customLabel: string;
26
+ usage: string;
27
+ approvalLine(label: string, approval: string): string;
28
+ approvalLabel(value: string): string;
29
+ switchSucceeded(label: string, hint: string): string;
30
+ unknownPreset(name: string, available: string): string;
31
+ switchedElsewhere(text: string): string;
32
+ }
33
+ /** Official display label for one preset; unknown presets keep their raw name. */
34
+ export declare function presetLabel(lang: Lang, name: string): string;
35
+ export declare function t(lang: Lang): Dict;
36
+ /**
37
+ * Resolve the effective language.
38
+ *
39
+ * Overload note: `resolveLang(provider)` is treated as `lang: auto` with that
40
+ * settings provider (kept for the original call sites).
41
+ */
42
+ export declare function resolveLang(langOrProvider?: LangSetting | (() => unknown), maybeProvider?: () => unknown): Lang;
package/dist/i18n.js ADDED
@@ -0,0 +1,94 @@
1
+ /** Official dsh preset labels (client ui-permission-presets locales). */
2
+ const ZH_LABELS = {
3
+ 'read-only': '仅可查看',
4
+ 'workspace-write': '工作区内修改',
5
+ 'danger-full-access': '完全权限',
6
+ auto: '自动审查(EXP)',
7
+ };
8
+ const EN_LABELS = {
9
+ 'read-only': 'Read Only',
10
+ 'workspace-write': 'Workspace Write',
11
+ 'danger-full-access': 'Full access',
12
+ auto: 'Auto review (EXP)',
13
+ };
14
+ /** Official display label for one preset; unknown presets keep their raw name. */
15
+ export function presetLabel(lang, name) {
16
+ const labels = lang === 'en' ? EN_LABELS : ZH_LABELS;
17
+ return labels[name] ?? name;
18
+ }
19
+ function approvalLabelZh(value) {
20
+ if (value === 'ask')
21
+ return '人工确认';
22
+ if (value === 'never')
23
+ return '从不';
24
+ return value;
25
+ }
26
+ function approvalLabelEn(value) {
27
+ if (value === 'ask')
28
+ return 'manual confirmation';
29
+ if (value === 'never')
30
+ return 'never';
31
+ return value;
32
+ }
33
+ const ZH = {
34
+ permissionTitle: (label) => '权限 · 当前:' + label,
35
+ customLabel: '自定义(不匹配任何预设)',
36
+ usage: '用法:/permission <档位>,或点下方按钮',
37
+ approvalLine: (label, approval) => '档位 ' + label + ' · 审批 ' + approval,
38
+ approvalLabel: approvalLabelZh,
39
+ switchSucceeded: (label, hint) => '已切换到 ' + label + '(' + hint + ')',
40
+ unknownPreset: (name, available) => '未知档位 ' + name + ',可用:' + available,
41
+ switchedElsewhere: (text) => '已执行 /permission(dsh 原文:' + text + ')',
42
+ };
43
+ const EN = {
44
+ permissionTitle: (label) => 'Permission · current: ' + label,
45
+ customLabel: 'Custom (matches no preset)',
46
+ usage: 'Usage: /permission <preset>, or tap a button below',
47
+ approvalLine: (label, approval) => 'preset ' + label + ' · approval ' + approval,
48
+ approvalLabel: approvalLabelEn,
49
+ switchSucceeded: (label, hint) => 'Switched to ' + label + ' (' + hint + ')',
50
+ unknownPreset: (name, available) => 'Unknown preset ' + name + '; available: ' + available,
51
+ switchedElsewhere: (text) => 'Ran /permission (dsh output: ' + text + ')',
52
+ };
53
+ export function t(lang) {
54
+ return lang === 'en' ? EN : ZH;
55
+ }
56
+ /**
57
+ * Resolve the effective language.
58
+ *
59
+ * Overload note: `resolveLang(provider)` is treated as `lang: auto` with that
60
+ * settings provider (kept for the original call sites).
61
+ */
62
+ export function resolveLang(langOrProvider, maybeProvider) {
63
+ const configured = typeof langOrProvider === 'function' ? 'auto' : (langOrProvider ?? 'auto');
64
+ const provider = typeof langOrProvider === 'function' ? langOrProvider : maybeProvider;
65
+ // Explicit configuration always wins over settings and environment.
66
+ if (configured === 'zh' || configured === 'en')
67
+ return configured;
68
+ try {
69
+ const settings = provider?.();
70
+ if (settings !== undefined && settings !== null) {
71
+ const candidate = settings;
72
+ let section;
73
+ if (typeof candidate.get === 'function')
74
+ section = candidate.get('locale');
75
+ else if (typeof candidate.describe === 'function') {
76
+ section = candidate.describe().find((entry) => entry.ns === 'locale')?.value;
77
+ }
78
+ const record = section;
79
+ const value = typeof record?.get === 'function' ? record.get() : record;
80
+ const preference = value?.preference;
81
+ if (preference === 'zh' || preference === 'en')
82
+ return preference;
83
+ }
84
+ }
85
+ catch {
86
+ // Missing/partial settings service: fall through to environment.
87
+ }
88
+ const locale = (process.env['LC_ALL'] ?? process.env['LANG'] ?? '').trim().toLowerCase();
89
+ if (locale.startsWith('zh'))
90
+ return 'zh';
91
+ if (locale.startsWith('en'))
92
+ return 'en';
93
+ return 'zh';
94
+ }