dsh-lark-agent 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import { LarkCli } from '../lark-cli.js';
8
8
  import { LockHeldError } from '../lock.js';
9
9
  import { BOT_SCOPES, CARD_SCOPE, LarkPlatformError } from './lark-platform.js';
10
10
  import { createChallenge, PairingError, waitForPairing } from './pairing.js';
11
- import { documentScopesReady } from '../rich-scopes.js';
11
+ import { defaultScopesReady } from '../rich-scopes.js';
12
12
  const restoreDelay = (attempt) => Math.min(10 * 60_000, 5_000 * 2 ** Math.min(attempt, 10));
13
13
  const messages = {
14
14
  idle: '把助手带到飞书,先用飞书确认创建。',
@@ -167,7 +167,7 @@ export class ConnectionController {
167
167
  this.documentJob = this.deps.platform.inspect(record.profile, aborter.signal).then(inspection => {
168
168
  if (!aborter.signal.aborted && !this.closed && this.record?.appId === record.appId) {
169
169
  if (inspection.appId === record.appId && (!record.botOpenId || inspection.botOpenId === record.botOpenId)) {
170
- this.documentsReady = inspection.scopeStatus === 'verified' && documentScopesReady(inspection.grantedScopes);
170
+ this.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
171
171
  }
172
172
  }
173
173
  }).catch(() => { })
@@ -202,13 +202,13 @@ export class ConnectionController {
202
202
  if (this.documentJob)
203
203
  return this.snapshot();
204
204
  const record = this.record, aborter = this.documentAborter = new AbortController();
205
- this.documentSetup = { phase: 'preparing', message: '正在准备文档与画板授权,聊天连接继续保持。' };
205
+ this.documentSetup = { phase: 'preparing', message: '正在准备默认权限授权,聊天连接继续保持。' };
206
206
  this.documentJob = this.deps.platform.enableDocuments(record.profile, info => {
207
207
  const url = officialDestination(info.url), expiresAt = Date.now() + info.expireIn * 1000;
208
208
  void (this.deps.qr ?? (url => QRCode.toDataURL(url, { width: 256, margin: 2 })))(url).then(image => {
209
209
  if (aborter.signal.aborted || this.closed || this.documentAborter !== aborter)
210
210
  return;
211
- this.documentSetup = { phase: 'scanning', message: '用飞书扫码补充文档与画板权限,无需重新创建或配对机器人。', qr: { url, image, expiresAt } };
211
+ this.documentSetup = { phase: 'scanning', message: '用飞书扫码补充实时回复、文档与画板权限,无需重新创建或配对机器人。', qr: { url, image, expiresAt } };
212
212
  }).catch(() => { if (!aborter.signal.aborted) {
213
213
  this.documentSetup = { phase: 'error', message: '二维码未能生成,请重试。' };
214
214
  aborter.abort();
@@ -352,7 +352,7 @@ export class ConnectionController {
352
352
  this.assertCurrent(signal);
353
353
  let record = this.record;
354
354
  let inspection = await this.deps.platform.inspect(record.profile, signal);
355
- this.documentsReady = inspection.scopeStatus === 'verified' && documentScopesReady(inspection.grantedScopes);
355
+ this.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
356
356
  this.assertCurrent(signal);
357
357
  if (inspection.appId !== record.appId || (record.botOpenId && inspection.botOpenId && record.botOpenId !== inspection.botOpenId)) {
358
358
  throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
@@ -442,8 +442,10 @@ export class ConnectionController {
442
442
  const cwd = join(directory, 'workspace');
443
443
  await mkdir(cwd, { recursive: true, mode: 0o700 });
444
444
  return parseConfig({ profile: record.profile, botOpenId: record.botOpenId, stateDir: join(directory, 'state'),
445
- executable: this.deps.executable ?? 'lark-cli', users: [{ openId: record.ownerOpenId, direct: true,
446
- groupChats: [], cwd, allowedTools: [] }], cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady }, directory);
445
+ executable: this.deps.executable ?? 'lark-cli', users: [{ openId: record.ownerOpenId, direct: true, directMode: 'harness',
446
+ groupChats: [], cwd, allowedTools: [] }], cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady,
447
+ // Without the CardKit scope a live card cannot be created and replies fall back to ordinary messages.
448
+ streamingCards: true }, directory);
447
449
  }
448
450
  async bind(message, signal) {
449
451
  this.assertCurrent(signal);
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { BOT_MENU_EVENT } from '../bot-menu.js';
3
- import { DOCUMENT_SCOPES, documentScopesReady } from '../rich-scopes.js';
3
+ import { DOCUMENT_SCOPES, STREAMING_SCOPE, defaultScopesReady } from '../rich-scopes.js';
4
4
  export class LarkPlatformError extends Error {
5
5
  code;
6
6
  constructor(code) {
@@ -197,7 +197,7 @@ export class LarkPlatform {
197
197
  appPreset: { name: request.name, desc: request.description ?? '在飞书中与 dsh 个人助手对话' },
198
198
  addons: {
199
199
  preset: false,
200
- scopes: { tenant: [...new Set([...BOT_SCOPES, ...SETUP_SCOPES, ...DOCUMENT_SCOPES, ...(request.cardInteractions ? [CARD_SCOPE] : []),
200
+ scopes: { tenant: [...new Set([...BOT_SCOPES, ...SETUP_SCOPES, ...DOCUMENT_SCOPES, STREAMING_SCOPE, ...(request.cardInteractions ? [CARD_SCOPE] : []),
201
201
  ...(request.loadingReaction ? [REACTION_SCOPE] : [])])] },
202
202
  events: { items: { tenant: ['im.message.receive_v1', BOT_MENU_EVENT] } },
203
203
  ...(request.cardInteractions ? { callbacks: { items: ['card.action.trigger'] } } : {}),
@@ -250,10 +250,10 @@ export class LarkPlatform {
250
250
  if (current.appId !== selected.appId)
251
251
  fail('APP_MISMATCH');
252
252
  // Another host may already have completed the upgrade; never ask for another scan just to refresh UI state.
253
- if (current.scopeStatus === 'verified' && documentScopesReady(current.grantedScopes))
253
+ if (current.scopeStatus === 'verified' && defaultScopesReady(current.grantedScopes))
254
254
  return;
255
255
  const result = await this.registrar({ source: 'dsh-larkbot', appId: selected.appId, signal,
256
- addons: { preset: false, scopes: { tenant: [...DOCUMENT_SCOPES] } },
256
+ addons: { preset: false, scopes: { tenant: [...DOCUMENT_SCOPES, STREAMING_SCOPE] } },
257
257
  onQRCodeReady: info => {
258
258
  checkSignal(signal);
259
259
  let url;
@@ -274,7 +274,7 @@ export class LarkPlatform {
274
274
  if (result.client_id !== selected.appId || (result.user_info?.tenant_brand && result.user_info.tenant_brand !== selected.brand))
275
275
  fail('APP_MISMATCH');
276
276
  const inspection = await this.inspect(profile, signal);
277
- if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !documentScopesReady(inspection.grantedScopes))
277
+ if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !defaultScopesReady(inspection.grantedScopes))
278
278
  fail('MISSING_SCOPE');
279
279
  }
280
280
  catch (error) {
@@ -0,0 +1,20 @@
1
+ import type { PermissionOption } from './dsh-runtime.js';
2
+ import type { ReplyAction, ReplyPanel } from './replies.js';
3
+ /** What the chat shows: the effective preset when a session is live, else the chat's saved choice. */
4
+ export interface PermissionView {
5
+ options: PermissionOption[];
6
+ current?: string;
7
+ }
8
+ /** Where the card is: idle, switching to an option, switched, refused with a reason, or refreshed with a notice. */
9
+ export interface PanelState {
10
+ changed?: boolean;
11
+ pending?: string;
12
+ error?: string;
13
+ notice?: string;
14
+ }
15
+ /** Presets that let Harness act without asking get a second confirmation. */
16
+ export declare const unattended: (id: string) => boolean;
17
+ export declare function permissionStateText(view: PermissionView): string;
18
+ type Issue = (label: string, command: string, primary: boolean, confirm?: ReplyAction['confirm']) => ReplyAction | undefined;
19
+ export declare function permissionPanel(view: PermissionView, issue: Issue, state?: PanelState): ReplyPanel;
20
+ export {};
@@ -0,0 +1,37 @@
1
+ /** Presets that let Harness act without asking get a second confirmation. */
2
+ export const unattended = (id) => /full|danger|auto/.test(id);
3
+ function currentName(view) {
4
+ if (!view.current)
5
+ return '跟随电脑上的默认设置';
6
+ return optionName(view, view.current) ?? '自定义(在电脑上调整过)';
7
+ }
8
+ const optionName = (view, id) => view.options.find(option => option.id === id)?.name;
9
+ export function permissionStateText(view) {
10
+ return `当前权限模式:${currentName(view)}。\n可选:${view.options.map(option => option.name).join('、')}。只影响这个私聊里的对话。`;
11
+ }
12
+ export function permissionPanel(view, issue, state = {}) {
13
+ const actions = [];
14
+ for (const option of view.options) {
15
+ const current = option.id === view.current;
16
+ // While a switch runs nothing is clickable; afterwards the current option is shown, not offered.
17
+ if (state.pending || current) {
18
+ actions.push({ label: `${option.id === state.pending ? '⏳ ' : current ? '✓ ' : ''}${option.name}`, value: {}, disabled: true });
19
+ continue;
20
+ }
21
+ const action = issue(option.name, `/permission use ${option.id}`, false, unattended(option.id) ? { title: `切换到「${option.name}」?`,
22
+ text: '之后 Harness 执行命令、修改文件都不再询问,在飞书发一句话就能直接操作这台电脑。随时可以切回。' } : undefined);
23
+ if (action)
24
+ actions.push(action);
25
+ }
26
+ const pendingName = state.pending ? optionName(view, state.pending) ?? state.pending : undefined;
27
+ const status = state.error ? { text: state.error, tone: 'error' }
28
+ : pendingName ? { text: `正在切换到「${pendingName}」…`, tone: 'pending' }
29
+ : state.changed ? { text: `已切换到「${currentName(view)}」`, tone: 'success' }
30
+ : { text: state.notice ? `${state.notice}\n当前:${currentName(view)}` : `当前:${currentName(view)}` };
31
+ return { title: state.changed && !state.error ? '权限模式已更新' : '权限模式', subtitle: '只影响这个私聊里的对话',
32
+ tone: status.tone ?? 'normal', status, summary: status.text,
33
+ sections: view.options.filter(option => option.description).map(option => ({ title: option.name, text: option.description })),
34
+ actions, actionColumns: 1,
35
+ note: state.pending ? '完成后会在这张卡片上更新,无需重复点击。' : '需要确认的操作由这里的模式决定;电脑上其他会话和默认设置不受影响。' };
36
+ }
37
+ //# sourceMappingURL=permission-controls.js.map
@@ -0,0 +1,26 @@
1
+ /** Both keys must be derived by the bridge from the verified sender and chat. */
2
+ export interface PermissionPreferenceContext {
3
+ ownerKey: string;
4
+ conversationKey: string;
5
+ }
6
+ /** Safe for chat replies: no identity keys, paths or I/O details. */
7
+ export declare class PermissionPreferencesError extends Error {
8
+ readonly code: 'INVALID_INPUT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE';
9
+ constructor(code: 'INVALID_INPUT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE');
10
+ }
11
+ /**
12
+ * The permission preset a private chat chose in Feishu. It applies to that
13
+ * chat's sessions only; the host's own default for other sessions is untouched.
14
+ * One bot process owns this store under its state-directory lock.
15
+ */
16
+ export declare class PermissionPreferencesStore {
17
+ private readonly directory;
18
+ private document;
19
+ private tail;
20
+ private uncertain;
21
+ private constructor();
22
+ static open(directory: string): Promise<PermissionPreferencesStore>;
23
+ get(context: PermissionPreferenceContext): Promise<string | undefined>;
24
+ set(context: PermissionPreferenceContext, preset: string): Promise<void>;
25
+ private persist;
26
+ }
@@ -0,0 +1,169 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ /** Safe for chat replies: no identity keys, paths or I/O details. */
6
+ export class PermissionPreferencesError extends Error {
7
+ code;
8
+ constructor(code) {
9
+ super({
10
+ INVALID_INPUT: '权限设置参数无效,请重新打开权限模式后选择。',
11
+ CAPACITY: '权限设置数量已达到上限,已有设置会保留。',
12
+ CORRUPT: '权限设置无法安全读取,请管理员检查本地状态文件;已有记录未被清空。',
13
+ WRITE_FAILED: '权限设置未能保存,请稍后重试。',
14
+ UNAVAILABLE: '权限设置的保存结果未能确认,请管理员检查后重新启动。',
15
+ }[code]);
16
+ this.code = code;
17
+ this.name = 'PermissionPreferencesError';
18
+ }
19
+ }
20
+ const FILE_NAME = 'permission-preferences.json';
21
+ const MAX_ENTRIES = 10_000;
22
+ const MAX_FILE_BYTES = 4 * 1024 * 1024;
23
+ const PRESET = /^[a-z][a-z0-9-]{0,39}$/;
24
+ const KEY = (value) => typeof value === 'string' && value.length > 0 && value.length <= 512
25
+ && !/[\u0000-\u001f\u007f]/.test(value);
26
+ function snapshot(context) {
27
+ if (!context || typeof context !== 'object' || !KEY(context.ownerKey) || !KEY(context.conversationKey)) {
28
+ throw new PermissionPreferencesError('INVALID_INPUT');
29
+ }
30
+ return { ownerKey: context.ownerKey, conversationKey: context.conversationKey };
31
+ }
32
+ function parse(value) {
33
+ const input = value;
34
+ if (!input || typeof input !== 'object' || Object.keys(input).some(key => key !== 'version' && key !== 'entries')
35
+ || input.version !== 1 || !Array.isArray(input.entries) || input.entries.length > MAX_ENTRIES)
36
+ throw new Error();
37
+ const seen = new Set();
38
+ const entries = input.entries.map((raw) => {
39
+ const row = raw;
40
+ if (!row || typeof row !== 'object' || Array.isArray(row) || Object.keys(row).some(key => !['ownerKey', 'conversationKey', 'preset'].includes(key))
41
+ || typeof row.preset !== 'string' || !PRESET.test(row.preset))
42
+ throw new Error();
43
+ const context = snapshot(row);
44
+ const identity = JSON.stringify([context.ownerKey, context.conversationKey]);
45
+ if (seen.has(identity))
46
+ throw new Error();
47
+ seen.add(identity);
48
+ return { ...context, preset: row.preset };
49
+ });
50
+ return { version: 1, entries };
51
+ }
52
+ /**
53
+ * The permission preset a private chat chose in Feishu. It applies to that
54
+ * chat's sessions only; the host's own default for other sessions is untouched.
55
+ * One bot process owns this store under its state-directory lock.
56
+ */
57
+ export class PermissionPreferencesStore {
58
+ directory;
59
+ document = { version: 1, entries: [] };
60
+ tail = Promise.resolve();
61
+ uncertain = false;
62
+ constructor(directory) {
63
+ this.directory = directory;
64
+ }
65
+ static async open(directory) {
66
+ if (typeof directory !== 'string' || !directory.trim() || directory.includes('\0'))
67
+ throw new PermissionPreferencesError('INVALID_INPUT');
68
+ const store = new PermissionPreferencesStore(resolve(directory));
69
+ try {
70
+ await mkdir(store.directory, { recursive: true, mode: 0o700 });
71
+ const info = await lstat(store.directory);
72
+ if (!info.isDirectory() || info.isSymbolicLink())
73
+ throw new Error();
74
+ await chmod(store.directory, 0o700);
75
+ const path = join(store.directory, FILE_NAME);
76
+ let file;
77
+ try {
78
+ file = await lstat(path);
79
+ }
80
+ catch (error) {
81
+ if (error.code === 'ENOENT')
82
+ return store;
83
+ throw error;
84
+ }
85
+ if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_FILE_BYTES)
86
+ throw new Error();
87
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
88
+ try {
89
+ const actual = await handle.stat();
90
+ if (!actual.isFile() || actual.dev !== file.dev || actual.ino !== file.ino || actual.size > MAX_FILE_BYTES)
91
+ throw new Error();
92
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(await handle.readFile());
93
+ if (Buffer.byteLength(text) !== actual.size)
94
+ throw new Error();
95
+ store.document = parse(JSON.parse(text));
96
+ await handle.chmod(0o600);
97
+ }
98
+ finally {
99
+ await handle.close();
100
+ }
101
+ return store;
102
+ }
103
+ catch {
104
+ throw new PermissionPreferencesError('CORRUPT');
105
+ }
106
+ }
107
+ async get(context) {
108
+ const identity = snapshot(context);
109
+ await this.tail;
110
+ if (this.uncertain)
111
+ throw new PermissionPreferencesError('UNAVAILABLE');
112
+ return this.document.entries.find(entry => entry.ownerKey === identity.ownerKey && entry.conversationKey === identity.conversationKey)?.preset;
113
+ }
114
+ async set(context, preset) {
115
+ const identity = snapshot(context);
116
+ if (typeof preset !== 'string' || !PRESET.test(preset))
117
+ throw new PermissionPreferencesError('INVALID_INPUT');
118
+ const job = this.tail.then(async () => {
119
+ if (this.uncertain)
120
+ throw new PermissionPreferencesError('UNAVAILABLE');
121
+ const previous = this.document.entries.find(entry => entry.ownerKey === identity.ownerKey && entry.conversationKey === identity.conversationKey);
122
+ if (previous?.preset === preset)
123
+ return;
124
+ if (!previous && this.document.entries.length >= MAX_ENTRIES)
125
+ throw new PermissionPreferencesError('CAPACITY');
126
+ const value = { ...identity, preset };
127
+ const entries = previous ? this.document.entries.map(entry => entry === previous ? value : entry) : [...this.document.entries, value];
128
+ const draft = { version: 1, entries };
129
+ await this.persist(draft);
130
+ this.document = draft;
131
+ });
132
+ this.tail = job.catch(() => undefined);
133
+ return job;
134
+ }
135
+ async persist(document) {
136
+ const content = JSON.stringify(document) + '\n';
137
+ if (Buffer.byteLength(content) > MAX_FILE_BYTES)
138
+ throw new PermissionPreferencesError('CAPACITY');
139
+ const temporary = join(this.directory, `permission-preferences.${randomUUID()}.tmp`);
140
+ let publicationAttempted = false;
141
+ try {
142
+ const handle = await open(temporary, 'wx', 0o600);
143
+ try {
144
+ await handle.writeFile(content, 'utf8');
145
+ await handle.sync();
146
+ }
147
+ finally {
148
+ await handle.close();
149
+ }
150
+ // A rejected rename may leave publication uncertain; never overwrite from stale memory afterwards.
151
+ publicationAttempted = true;
152
+ await rename(temporary, join(this.directory, FILE_NAME));
153
+ const directory = await open(this.directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
154
+ try {
155
+ await directory.sync();
156
+ }
157
+ finally {
158
+ await directory.close();
159
+ }
160
+ }
161
+ catch {
162
+ if (publicationAttempted)
163
+ this.uncertain = true;
164
+ await unlink(temporary).catch(() => undefined);
165
+ throw new PermissionPreferencesError(publicationAttempted ? 'UNAVAILABLE' : 'WRITE_FAILED');
166
+ }
167
+ }
168
+ }
169
+ //# sourceMappingURL=permission-preferences.js.map
@@ -55,9 +55,16 @@ export type PresentationDraft = DraftBase & ({
55
55
  kind: 'form';
56
56
  content: FormContent;
57
57
  });
58
+ /** A file the host's own delivery tool presented; the path is absolute and not yet validated for upload. */
59
+ export interface DeliveredFile {
60
+ path: string;
61
+ description: string;
62
+ }
58
63
  export interface TurnResult {
59
64
  text: string;
60
65
  artifacts: PresentationDraft[];
66
+ reasoning?: string;
67
+ files?: DeliveredFile[];
61
68
  }
62
69
  export declare const PRESENT_TOOL = "lark_present";
63
70
  export declare const PRESENTATION_GUIDE = "\u9700\u8981\u56FE\u8868\u3001\u6D41\u7A0B\u56FE\u3001\u957F\u62A5\u544A\u6216\u6536\u96C6\u9009\u9879\u65F6\uFF0C\u53EF\u4EE5\u8C03\u7528 lark_present\u3002\u666E\u901A\u804A\u5929\u548C\u4EE3\u7801\u89E3\u91CA\u76F4\u63A5\u7528\u6587\u5B57\u3002\u5DE5\u5177\u53EA\u51C6\u5907\u5F53\u524D\u804A\u5929\u7684\u5C55\u793A\u5185\u5BB9\uFF0C\u4E0D\u6267\u884C HTML/JavaScript\uFF0C\u4E0D\u8BFB\u53D6\u6587\u4EF6\uFF0C\u4E0D\u6269\u5927\u4EFB\u4F55\u4EBA\u7684\u6743\u9650\u3002\u56FE\u8868\u6570\u636E\u5FC5\u987B\u6765\u81EA\u7528\u6237\u6216\u5DF2\u6838\u5B9E\u7684\u8D44\u6599\uFF1B\u793A\u4F8B\u6570\u636E\u5FC5\u987B\u5728\u6807\u9898\u6216\u6458\u8981\u6807\u660E\u201C\u793A\u4F8B\u201D\u3002\u8868\u5355\u53EA\u6536\u96C6\u5B8C\u6210\u5F53\u524D\u4EFB\u52A1\u5FC5\u9700\u7684\u666E\u901A\u4FE1\u606F\uFF0C\u4E0D\u7D22\u53D6\u5BC6\u7801\u3001\u5BC6\u94A5\u6216\u5176\u4ED6\u654F\u611F\u51ED\u636E\u3002\u4E0D\u8981\u91CD\u590D\u8F93\u51FA\u5DF2\u63D0\u4EA4\u5DE5\u5177\u7684\u6574\u4EFD\u5185\u5BB9\uFF0C\u4E5F\u4E0D\u8981\u58F0\u79F0\u5DF2\u521B\u5EFA\u4E91\u6587\u6863\u6216\u94FE\u63A5\uFF1B\u4EE5\u7CFB\u7EDF\u6700\u7EC8\u5C55\u793A\u7ED3\u679C\u4E3A\u51C6\u3002\u4FEE\u6539\u5DF2\u5C55\u793A\u4F5C\u54C1\u65F6\uFF0C\u53EA\u4F7F\u7528\u5F53\u524D\u804A\u5929\u63D0\u4F9B\u7684\u4F5C\u54C1 ID \u4F5C\u4E3A replaces\uFF0C\u4FDD\u7559\u539F\u7248\u3002";
package/dist/replies.d.ts CHANGED
@@ -17,11 +17,21 @@ export interface ReplyAction {
17
17
  title: string;
18
18
  text: string;
19
19
  };
20
+ /** Shown but not clickable, such as the current choice or every option while a switch runs. */
21
+ disabled?: boolean;
20
22
  }
23
+ /** normal: blue; pending: a switch is running; success: green; error: red; muted: closed or expired. */
24
+ export type PanelTone = 'normal' | 'pending' | 'success' | 'error' | 'muted';
21
25
  export interface ReplyPanel {
22
26
  title: string;
23
27
  summary: string;
24
28
  subtitle?: string;
29
+ tone?: PanelTone;
30
+ /** One highlighted line under the header: the current choice, a running switch, or its result. */
31
+ status?: {
32
+ text: string;
33
+ tone?: PanelTone;
34
+ };
25
35
  metrics?: {
26
36
  label: string;
27
37
  value: string;
@@ -41,6 +51,11 @@ export interface ReplyPanel {
41
51
  * Text messages understand native mentions even inside Markdown code fences.
42
52
  */
43
53
  export declare function safePlainText(text: string): string;
54
+ /** Model text for a card markdown element, with platform extensions made inert. */
55
+ export declare function cardMarkdown(text: string): string;
56
+ /** Top-level blocks split at blank lines outside code fences, for cutting long text without breaking Markdown. */
57
+ export declare function markdownBlocks(text: string): string[];
58
+ export declare function plainLabel(text: string, limit: number): string;
44
59
  /** Trusted application panels only. Model strings never choose components or callback values. */
45
60
  export declare function renderPanel(panel: ReplyPanel): ReplyPayload;
46
61
  /** Select presentation without asking the model to generate arbitrary card JSON. */
package/dist/replies.js CHANGED
@@ -130,7 +130,15 @@ function markdown(text) {
130
130
  blocks.push(block);
131
131
  return { text: output, balanced: !fence, blocks };
132
132
  }
133
- function plainLabel(text, limit) {
133
+ /** Model text for a card markdown element, with platform extensions made inert. */
134
+ export function cardMarkdown(text) {
135
+ return markdown(text).text;
136
+ }
137
+ /** Top-level blocks split at blank lines outside code fences, for cutting long text without breaking Markdown. */
138
+ export function markdownBlocks(text) {
139
+ return markdown(text).blocks;
140
+ }
141
+ export function plainLabel(text, limit) {
134
142
  return pointSlice(text.replace(/<[^>]*>/g, '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
135
143
  .replace(/!?(\[([^\]]+)\])\([^)]*\)/g, '$2').replace(/[`*#_~]/g, '').replace(/\s+/g, ' ').trim(), limit);
136
144
  }
@@ -149,15 +157,27 @@ const column = (elements, extra = {}) => ({
149
157
  tag: 'column', width: 'weighted', weight: 1, direction: 'vertical',
150
158
  vertical_spacing: '4px', padding: '0px', margin: '0px', elements, ...extra,
151
159
  });
160
+ const TONES = {
161
+ normal: { header: 'blue', background: 'grey-50', icon: 'info_outlined', color: 'grey' },
162
+ pending: { header: 'wathet', background: 'blue-50', icon: 'loading_outlined', color: 'blue' },
163
+ success: { header: 'green', background: 'green-50', icon: 'done_outlined', color: 'green' },
164
+ error: { header: 'red', background: 'red-50', icon: 'warning_outlined', color: 'red' },
165
+ muted: { header: 'grey', background: 'grey-50', icon: 'time_outlined', color: 'grey' },
166
+ };
152
167
  /** Trusted application panels only. Model strings never choose components or callback values. */
153
168
  export function renderPanel(panel) {
154
169
  const metrics = (panel.metrics ?? []).filter(metric => metric.label.trim() || metric.value.trim());
155
- const fallbackText = safePlainText([panel.title, panel.subtitle,
170
+ const fallbackText = safePlainText([panel.title, panel.subtitle, panel.status?.text,
156
171
  ...metrics.map(metric => [`${metric.label}:${metric.value}`, metric.note].filter(Boolean).join('\n')),
157
172
  ...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')), panel.note]
158
173
  .filter(Boolean).join('\n\n'));
159
174
  const elements = [];
160
175
  const actions = panel.actions ?? [];
176
+ if (panel.status?.text.trim()) {
177
+ const tone = TONES[panel.status.tone ?? 'normal'];
178
+ elements.push({ tag: 'column_set', flex_mode: 'none', margin: '0px', columns: [column([{ ...md(panel.status.text),
179
+ icon: { tag: 'standard_icon', token: tone.icon, color: tone.color } }], { background_style: tone.background, padding: '8px 12px 8px 12px' })] });
180
+ }
161
181
  for (let start = 0; start < metrics.length; start += 2) {
162
182
  elements.push({ tag: 'column_set', flex_mode: 'none', horizontal_spacing: '8px', margin: '0px',
163
183
  columns: metrics.slice(start, start + 2).map(metric => column([
@@ -198,8 +218,8 @@ export function renderPanel(panel) {
198
218
  for (let start = 0; start < actions.length; start += actionColumns) {
199
219
  elements.push({ tag: 'column_set', flex_mode: panel.actionColumns ? 'none' : 'flow', horizontal_spacing: '8px', margin: '0px', columns: actions.slice(start, start + actionColumns).map(action => column([
200
220
  { tag: 'button', text: title(plainLabel(action.label, panel.actionColumns === 1 ? 64 : 24)), type: action.primary ? 'primary' : 'default', width: 'fill', margin: '0px',
201
- behaviors: [{ type: 'callback', value: { ...action.value } }],
202
- ...(action.confirm ? {
221
+ ...(action.disabled ? { disabled: true } : { behaviors: [{ type: 'callback', value: { ...action.value } }] }),
222
+ ...(action.confirm && !action.disabled ? {
203
223
  icon: { tag: 'standard_icon', token: 'warning_outlined' },
204
224
  confirm: { title: title(plainLabel(action.confirm.title, 80)), text: title(plainLabel(action.confirm.text, 300)) },
205
225
  } : {}),
@@ -214,9 +234,10 @@ export function renderPanel(panel) {
214
234
  return { kind: 'text', text: fallbackText };
215
235
  const card = {
216
236
  schema: '2.0',
217
- config: { compact_width: false, width_mode: 'default', enable_forward: false, enable_forward_interaction: false,
237
+ // Shared from the start, so a click can update this same card in place.
238
+ config: { update_multi: true, compact_width: false, width_mode: 'default', enable_forward: false, enable_forward_interaction: false,
218
239
  enable_markdown_autolink: false, summary: { content: summary } },
219
- header: { template: 'blue', title: title(plainLabel(panel.title, 80)), padding: '10px 16px 10px 16px',
240
+ header: { template: TONES[panel.tone ?? 'normal'].header, title: title(plainLabel(panel.title, 80)), padding: '10px 16px 10px 16px',
220
241
  ...(panel.subtitle?.trim() ? { subtitle: title(safePlainText(panel.subtitle)), vertical_spacing: '4px' } : {}) },
221
242
  body: { direction: 'vertical', vertical_spacing: '8px', padding: '12px 16px 12px 16px', elements },
222
243
  };
@@ -1,3 +1,7 @@
1
1
  /** Default document/board capabilities, authorized at registration. Bot-only; no broad drive access. */
2
2
  export declare const DOCUMENT_SCOPES: readonly ["application:application:self_manage", "docx:document:create", "docx:document:write_only", "docx:document:readonly", "board:whiteboard:node:create", "board:whiteboard:node:read", "docs:permission.setting:write_only", "docs:permission.setting:read", "docs:permission.member:create", "docs:permission.member:retrieve", "im:resource:upload"];
3
+ /** Lets a harness-mode private chat stream its reply, reasoning and tool progress into one card. */
4
+ export declare const STREAMING_SCOPE = "cardkit:card:write";
3
5
  export declare function documentScopesReady(scopes: readonly string[], diagram?: boolean): boolean;
6
+ /** Everything the owner-confirmed "default permissions" step grants: documents, boards and streaming replies. */
7
+ export declare function defaultScopesReady(scopes: readonly string[]): boolean;
@@ -2,6 +2,8 @@
2
2
  export const DOCUMENT_SCOPES = ['application:application:self_manage', 'docx:document:create', 'docx:document:write_only', 'docx:document:readonly',
3
3
  'board:whiteboard:node:create', 'board:whiteboard:node:read', 'docs:permission.setting:write_only',
4
4
  'docs:permission.setting:read', 'docs:permission.member:create', 'docs:permission.member:retrieve', 'im:resource:upload'];
5
+ /** Lets a harness-mode private chat stream its reply, reasoning and tool progress into one card. */
6
+ export const STREAMING_SCOPE = 'cardkit:card:write';
5
7
  export function documentScopesReady(scopes, diagram = true) {
6
8
  const granted = new Set(scopes);
7
9
  const groups = [
@@ -12,4 +14,8 @@ export function documentScopesReady(scopes, diagram = true) {
12
14
  ];
13
15
  return groups.every(group => group.some(scope => granted.has(scope)));
14
16
  }
17
+ /** Everything the owner-confirmed "default permissions" step grants: documents, boards and streaming replies. */
18
+ export function defaultScopesReady(scopes) {
19
+ return documentScopesReady(scopes) && scopes.includes(STREAMING_SCOPE);
20
+ }
15
21
  //# sourceMappingURL=rich-scopes.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lark-agent",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Reach your DeepSeek Harness assistant from Feishu / Lark: a dsh plugin built on the official Lark CLI",
5
5
  "keywords": [
6
6
  "deepseek",
@@ -73,13 +73,13 @@
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@deepseek-ai/cordis": "4.0.1 || 4.0.5",
76
- "@deepseek-ai/dsh-agent": "0.1.1-rc.2 || 0.2.1-rc.2",
77
- "@deepseek-ai/dsh-llm": "0.1.1-rc.2 || 0.2.1-rc.2",
78
- "@deepseek-ai/dsh-session": "0.1.1-rc.2 || 0.2.1-rc.2",
79
- "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2 || 0.2.1-rc.2",
80
- "@deepseek-ai/dsh-tools": "0.1.1-rc.2 || 0.2.1-rc.2",
76
+ "@deepseek-ai/dsh-agent": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
77
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
78
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
79
+ "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
80
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3",
81
81
  "@deepseek-ai/schemastery": "3.18.1 || 3.18.5",
82
- "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2 || 0.2.1-rc.2"
82
+ "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.3"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@deepseek-ai/cordis": "4.0.1",