dsh-lark-agent 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ import { cardMarkdown, markdownBlocks, plainLabel } from './replies.js';
2
+ // CardKit rejects cards over 30 KB, measured on the card JSON.
3
+ const CARD_BYTES = 28_000;
4
+ const LIVE_STEP_BYTES = 6_000;
5
+ const LIVE_ANSWER_BYTES = 20_000;
6
+ const FINAL_STEP_BYTES = 8_000;
7
+ const MAX_KEPT_CHARS = 40_000;
8
+ // One card entity accepts at most 10 operations per second.
9
+ const STREAM_INTERVAL_MS = 500;
10
+ const FULL_INTERVAL_MS = 2_000;
11
+ // Feishu turns streaming mode off 10 minutes after it was last turned on.
12
+ const REOPEN_AFTER_MS = 9 * 60_000;
13
+ const TRUNCATED = '回复超过了飞书卡片的长度上限,后面的内容没有显示。完整回复在电脑上的这段 Harness 会话里,也可以让我整理成文档。';
14
+ const grey = (markdown) => `<font color='grey'>${markdown}</font>`;
15
+ const bytes = (value) => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
16
+ const code = (value) => '`' + value.replace(/`/g, 'ˋ') + '`';
17
+ /** The longest prefix of text within maxBytes, never splitting a code point. */
18
+ function head(text, maxBytes) {
19
+ let size = 0, end = 0;
20
+ for (const point of text) {
21
+ size += Buffer.byteLength(point, 'utf8');
22
+ if (size > maxBytes)
23
+ break;
24
+ end += point.length;
25
+ }
26
+ return text.slice(0, end);
27
+ }
28
+ /**
29
+ * A suffix of text within maxBytes. The cut moves in coarse steps so that
30
+ * successive updates mostly share a prefix, which keeps Feishu's typewriter
31
+ * effect instead of redrawing the whole element.
32
+ */
33
+ function tail(text, maxBytes) {
34
+ if (bytes(text) <= maxBytes)
35
+ return text;
36
+ let size = 0, start = text.length;
37
+ while (start > 0) {
38
+ const pair = start >= 2 && (text.charCodeAt(start - 1) & 0xfc00) === 0xdc00 && (text.charCodeAt(start - 2) & 0xfc00) === 0xd800;
39
+ const width = Buffer.byteLength(text.slice(start - (pair ? 2 : 1), start), 'utf8');
40
+ if (size + width > maxBytes)
41
+ break;
42
+ size += width;
43
+ start -= pair ? 2 : 1;
44
+ }
45
+ let cut = Math.ceil(start / 1_000) * 1_000;
46
+ const newline = text.indexOf('\n', cut);
47
+ if (newline >= 0 && newline - cut < 200)
48
+ cut = newline + 1;
49
+ if (cut < text.length && (text.charCodeAt(cut) & 0xfc00) === 0xdc00)
50
+ cut++;
51
+ return text.slice(Math.min(cut, text.length));
52
+ }
53
+ /**
54
+ * One streaming reply card: the answer, with reasoning and tool steps in a
55
+ * panel that stays folded unless the reader opens it. Only the initial reply
56
+ * is a send; every later operation updates the same card entity and is safe
57
+ * to supersede or repeat. A reply never continues in another message.
58
+ */
59
+ export class LiveReply {
60
+ transport;
61
+ target;
62
+ options;
63
+ phase = 'thinking';
64
+ steps = [];
65
+ trimmed = false;
66
+ answer = '';
67
+ separator = false;
68
+ mark = { steps: 0, tail: -1, answer: 0 };
69
+ tools = 0;
70
+ note;
71
+ cardId;
72
+ sequence = 0;
73
+ sent = new Map();
74
+ dirty = false;
75
+ layoutDirty = false;
76
+ degraded = false;
77
+ streamFailures = 0;
78
+ closed = false;
79
+ loop;
80
+ wake;
81
+ streamingSince = 0;
82
+ startedAt;
83
+ answeredAt;
84
+ finishedAt;
85
+ constructor(transport, target, options = {}) {
86
+ this.transport = transport;
87
+ this.target = target;
88
+ this.options = options;
89
+ this.startedAt = this.now();
90
+ }
91
+ /** Send the card. False means nothing was sent, so the caller keeps its ordinary reply. */
92
+ async open() {
93
+ try {
94
+ this.cardId = await this.transport.createCardEntity(this.card());
95
+ }
96
+ catch {
97
+ this.audit('live-card-unavailable');
98
+ return false;
99
+ }
100
+ this.streamingSince = this.now();
101
+ this.markSent();
102
+ try {
103
+ await this.transport.replyCardEntity(this.target.messageId, this.cardId, { idempotencyKey: this.target.idempotencyKey, inThread: this.target.inThread });
104
+ }
105
+ // The reply may still have been delivered. Never send another message for this
106
+ // turn; updating a card nobody received is harmless.
107
+ catch {
108
+ this.audit('live-card-delivery-unknown');
109
+ }
110
+ if (this.dirty)
111
+ this.schedule();
112
+ return true;
113
+ }
114
+ push(event) {
115
+ if (this.closed)
116
+ return;
117
+ switch (event.type) {
118
+ case 'attempt': {
119
+ const last = this.steps.at(-1);
120
+ this.mark = { steps: this.steps.length, tail: last?.kind === 'reasoning' ? last.text.length : -1, answer: this.answer.length };
121
+ this.separator = this.answer.length > 0;
122
+ break;
123
+ }
124
+ case 'discard': {
125
+ this.steps.length = Math.min(this.steps.length, this.mark.steps);
126
+ const last = this.steps.at(-1);
127
+ if (this.mark.tail >= 0 && last?.kind === 'reasoning')
128
+ last.text = last.text.slice(0, this.mark.tail);
129
+ this.answer = this.answer.slice(0, this.mark.answer);
130
+ this.separator = this.answer.length > 0;
131
+ break;
132
+ }
133
+ case 'reasoning': {
134
+ const last = this.steps.at(-1);
135
+ if (last?.kind === 'reasoning')
136
+ last.text += event.text;
137
+ else
138
+ this.steps.push({ kind: 'reasoning', text: event.text });
139
+ this.trim();
140
+ break;
141
+ }
142
+ case 'text':
143
+ if (this.answer.length > 70_000)
144
+ return;
145
+ if (this.separator) {
146
+ this.answer += '\n\n';
147
+ this.separator = false;
148
+ }
149
+ this.answer += event.text;
150
+ // The folded panel's title switches from "thinking" to the time it took.
151
+ if (this.phase === 'thinking') {
152
+ this.phase = 'answering';
153
+ this.answeredAt = this.now();
154
+ this.layoutDirty = true;
155
+ }
156
+ break;
157
+ case 'tool':
158
+ if (this.steps.some(step => step.kind === 'tool' && step.callId === event.callId))
159
+ return;
160
+ this.steps.push({ kind: 'tool', callId: event.callId, name: event.name, ...(event.detail ? { detail: event.detail } : {}), state: 'running' });
161
+ this.tools++;
162
+ this.layoutDirty = true;
163
+ this.trim();
164
+ break;
165
+ case 'tool-done': {
166
+ const step = this.steps.find(item => item.kind === 'tool' && item.callId === event.callId);
167
+ if (step?.kind !== 'tool')
168
+ return;
169
+ step.state = event.ok ? 'done' : 'failed';
170
+ break;
171
+ }
172
+ }
173
+ this.schedule();
174
+ }
175
+ /** Stop streaming and write the final card. True only when Feishu confirmed it. */
176
+ async finish(final) {
177
+ if (this.closed)
178
+ return false;
179
+ this.closed = true;
180
+ this.wake?.();
181
+ await this.loop;
182
+ this.finishedAt = this.now();
183
+ this.phase = final.phase;
184
+ if (final.answer !== undefined)
185
+ this.answer = final.answer;
186
+ if (final.reasoning && !this.steps.some(step => step.kind === 'reasoning'))
187
+ this.steps.unshift({ kind: 'reasoning', text: final.reasoning });
188
+ for (const step of this.steps)
189
+ if (step.kind === 'tool' && step.state === 'running')
190
+ step.state = final.phase === 'done' ? 'done' : 'failed';
191
+ this.note = final.note;
192
+ const card = this.finalCard();
193
+ if (!this.cardId)
194
+ return false;
195
+ for (let attempt = 0; attempt < 3; attempt++) {
196
+ try {
197
+ await this.transport.updateCardEntity(this.cardId, card, ++this.sequence);
198
+ return true;
199
+ }
200
+ catch {
201
+ if (attempt < 2)
202
+ await this.sleep(500 * 3 ** attempt);
203
+ }
204
+ }
205
+ this.audit('live-card-final-failed');
206
+ return false;
207
+ }
208
+ now() { return (this.options.now ?? Date.now)(); }
209
+ sleep(ms) { return (this.options.sleep ?? (wait => new Promise(done => setTimeout(done, wait))))(ms); }
210
+ audit(stage) { try {
211
+ this.options.audit?.(stage);
212
+ }
213
+ catch { /* Diagnostics only. */ } }
214
+ /** Keep only what the card can still show; the turn result stays authoritative. */
215
+ trim() {
216
+ let total = this.steps.reduce((sum, step) => sum + (step.kind === 'reasoning' ? step.text.length : 200), 0);
217
+ while (total > MAX_KEPT_CHARS && this.steps.length > 1) {
218
+ const removed = this.steps.shift();
219
+ total -= removed.kind === 'reasoning' ? removed.text.length : 200;
220
+ this.mark.steps = Math.max(0, this.mark.steps - 1);
221
+ this.trimmed = true;
222
+ }
223
+ const only = this.steps[0];
224
+ if (this.steps.length === 1 && only?.kind === 'reasoning' && only.text.length > MAX_KEPT_CHARS) {
225
+ const cut = only.text.length - MAX_KEPT_CHARS / 2;
226
+ only.text = only.text.slice(cut);
227
+ this.mark.tail = Math.max(-1, this.mark.tail - cut);
228
+ this.trimmed = true;
229
+ }
230
+ }
231
+ schedule() {
232
+ this.dirty = true;
233
+ if (!this.cardId || this.closed || this.loop)
234
+ return;
235
+ this.loop = this.run().finally(() => {
236
+ this.loop = undefined;
237
+ if (this.dirty && !this.closed)
238
+ this.schedule();
239
+ });
240
+ }
241
+ async run() {
242
+ while (this.dirty && !this.closed) {
243
+ this.dirty = false;
244
+ const started = this.now();
245
+ await this.flush();
246
+ const wait = (this.degraded ? FULL_INTERVAL_MS : STREAM_INTERVAL_MS) - (this.now() - started);
247
+ if (wait > 0 && !this.closed)
248
+ await this.pause(wait);
249
+ }
250
+ }
251
+ /** Pacing between updates; finishing the reply ends it early. */
252
+ async pause(ms) {
253
+ await Promise.race([this.sleep(ms), new Promise(done => { this.wake = done; })]);
254
+ this.wake = undefined;
255
+ }
256
+ async flush() {
257
+ const cardId = this.cardId;
258
+ if (this.degraded || this.layoutDirty || this.now() - this.streamingSince > REOPEN_AFTER_MS) {
259
+ this.layoutDirty = false;
260
+ try {
261
+ // A complete card also turns streaming mode back on.
262
+ await this.transport.updateCardEntity(cardId, this.card(), ++this.sequence);
263
+ this.streamingSince = this.now();
264
+ this.markSent();
265
+ this.degraded = this.streamFailures >= 3;
266
+ }
267
+ catch {
268
+ this.degraded = true;
269
+ }
270
+ return;
271
+ }
272
+ for (const [id, content] of [['think', this.thinkContent()], ['answer', this.answerContent()]]) {
273
+ if (this.closed)
274
+ return;
275
+ if (this.sent.get(id) === content)
276
+ continue;
277
+ try {
278
+ await this.transport.streamCardText(cardId, id, content, ++this.sequence);
279
+ this.sent.set(id, content);
280
+ }
281
+ catch {
282
+ this.streamFailures++;
283
+ this.degraded = true;
284
+ this.audit('live-card-degraded');
285
+ return;
286
+ }
287
+ }
288
+ }
289
+ markSent() {
290
+ this.sent.set('think', this.thinkContent());
291
+ this.sent.set('answer', this.answerContent());
292
+ }
293
+ stepsMarkdown(maxBytes) {
294
+ const parts = this.steps.map(step => step.kind === 'reasoning' ? cardMarkdown(step.text.trim())
295
+ : `${step.state === 'running' ? '⏳' : step.state === 'done' ? '✅' : '⚠️'} ${code(step.name)}${step.detail ? ' ' + code(step.detail) : ''}`)
296
+ .filter(Boolean);
297
+ const text = parts.join('\n\n');
298
+ const shown = tail(text, maxBytes);
299
+ return (this.trimmed || shown.length < text.length) && shown ? `${grey('…')}\n${shown}` : shown;
300
+ }
301
+ thinkContent() {
302
+ return this.stepsMarkdown(LIVE_STEP_BYTES) || grey('正在思考…');
303
+ }
304
+ answerContent() {
305
+ if (!this.answer)
306
+ return grey('…');
307
+ const shown = head(this.answer, LIVE_ANSWER_BYTES);
308
+ return cardMarkdown(shown) + (shown.length < this.answer.length ? `\n\n${grey('……')}` : '');
309
+ }
310
+ panelTitle() {
311
+ const tools = this.tools ? ` · 使用工具 ${this.tools} 次` : '';
312
+ if (this.phase === 'thinking')
313
+ return `💭 思考中…${tools}`;
314
+ const seconds = Math.max(1, Math.round(((this.answeredAt ?? this.finishedAt ?? this.now()) - this.startedAt) / 1000));
315
+ return `💭 思考了 ${seconds} 秒${tools}`;
316
+ }
317
+ /** The folded process panel. Readers open it when they want the reasoning and tool steps. */
318
+ panel(content) {
319
+ return {
320
+ tag: 'collapsible_panel', element_id: 'think_panel', expanded: false,
321
+ header: { title: { tag: 'markdown', content: this.panelTitle() }, vertical_align: 'center',
322
+ icon: { tag: 'standard_icon', token: 'down-small-ccm_outlined', size: '16px 16px' }, icon_position: 'follow_text', icon_expanded_angle: -180 },
323
+ border: { color: 'grey', corner_radius: '5px' }, vertical_spacing: '8px', padding: '8px 8px 8px 8px',
324
+ elements: [{ tag: 'markdown', element_id: 'think', content, text_size: 'notation' }],
325
+ };
326
+ }
327
+ /** Live layout when steps is undefined; a final layout otherwise ('' leaves out the panel). */
328
+ card(final) {
329
+ const elements = [];
330
+ if (!final)
331
+ elements.push(this.panel(this.thinkContent()));
332
+ else if (final.steps)
333
+ elements.push(this.panel(final.steps));
334
+ elements.push({ tag: 'markdown', element_id: 'answer',
335
+ content: final ? (final.answer.trim() ? cardMarkdown(final.answer) : grey('(没有文字回复)')) : this.answerContent() });
336
+ const note = [this.note, final?.truncated ? TRUNCATED : undefined].filter(Boolean).join(' ');
337
+ if (final && note)
338
+ elements.push({ tag: 'markdown', element_id: 'note', content: grey(cardMarkdown(note)), text_size: 'notation' });
339
+ const summary = final ? plainLabel(final.answer || this.note || '回复', 60) || '回复' : this.answer ? '正在回复…' : '正在思考…';
340
+ return {
341
+ schema: '2.0',
342
+ config: { update_multi: true, enable_forward: false, width_mode: 'default', summary: { content: summary }, streaming_mode: !final,
343
+ ...(final ? {} : { streaming_config: { print_frequency_ms: { default: 30 }, print_step: { default: 2 }, print_strategy: 'fast' } }) },
344
+ body: { direction: 'vertical', vertical_spacing: '8px', padding: '12px 16px 12px 16px', elements },
345
+ };
346
+ }
347
+ /** The answer comes first. The folded process only gets the space that is left. */
348
+ finalCard() {
349
+ // Reserve room for the folded title, so its time and tool count stay visible even beside a long answer.
350
+ const minimal = this.steps.length ? grey('思考过程较长,卡片里没有显示。') : '';
351
+ let answer = this.answer, truncated = false;
352
+ const fits = (candidate, steps) => bytes(this.card({ answer: candidate, steps, truncated })) <= CARD_BYTES;
353
+ if (!fits(answer, minimal)) {
354
+ truncated = true;
355
+ // Cut between top-level blocks so what is shown stays valid Markdown.
356
+ const blocks = markdownBlocks(this.answer);
357
+ let low = 1, high = blocks.length - 1, best = 0;
358
+ while (low <= high) {
359
+ const middle = (low + high) >> 1;
360
+ if (fits(blocks.slice(0, middle).join(''), minimal)) {
361
+ best = middle;
362
+ low = middle + 1;
363
+ }
364
+ else
365
+ high = middle - 1;
366
+ }
367
+ if (best > 0)
368
+ answer = blocks.slice(0, best).join('');
369
+ else {
370
+ // One oversized block, such as a long code listing: cut it by size.
371
+ let budget = 20_000;
372
+ answer = head(this.answer, budget);
373
+ while (answer && !fits(answer, minimal))
374
+ answer = head(this.answer, budget = Math.floor(budget * 0.7));
375
+ }
376
+ }
377
+ if (minimal) {
378
+ let budget = Math.min(FINAL_STEP_BYTES, CARD_BYTES - bytes(this.card({ answer, steps: '', truncated })) - 800);
379
+ while (budget > 300) {
380
+ const steps = this.stepsMarkdown(budget);
381
+ if (steps && fits(answer, steps))
382
+ return this.card({ answer, steps, truncated });
383
+ budget = Math.floor(budget * 0.7);
384
+ }
385
+ }
386
+ return this.card({ answer, steps: minimal, truncated });
387
+ }
388
+ }
389
+ //# sourceMappingURL=live-reply.js.map
@@ -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,13 @@
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
+ /** Presets that let Harness act without asking get a second confirmation. */
9
+ export declare const unattended: (id: string) => boolean;
10
+ export declare function permissionStateText(view: PermissionView): string;
11
+ type Issue = (label: string, command: string, primary: boolean, confirm?: ReplyAction['confirm']) => ReplyAction | undefined;
12
+ export declare function permissionPanel(view: PermissionView, issue: Issue, changed?: boolean): ReplyPanel;
13
+ export {};
@@ -0,0 +1,26 @@
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 view.options.find(option => option.id === view.current)?.name ?? '自定义(在电脑上调整过)';
7
+ }
8
+ export function permissionStateText(view) {
9
+ return `当前权限模式:${currentName(view)}。\n可选:${view.options.map(option => option.name).join('、')}。只影响这个私聊里的对话。`;
10
+ }
11
+ export function permissionPanel(view, issue, changed = false) {
12
+ const actions = [];
13
+ for (const option of view.options) {
14
+ const current = option.id === view.current;
15
+ const action = issue(`${current ? '✓ ' : ''}${option.name}`, `/permission use ${option.id}`, current, !current && unattended(option.id) ? { title: `切换到「${option.name}」?`,
16
+ text: '之后 Harness 执行命令、修改文件都不再询问,在飞书发一句话就能直接操作这台电脑。随时可以切回。' } : undefined);
17
+ if (action)
18
+ actions.push(action);
19
+ }
20
+ const summary = `当前:${currentName(view)}`;
21
+ return { title: changed ? '权限模式已更新' : '权限模式', subtitle: '只影响这个私聊里的对话', summary,
22
+ sections: [{ text: summary }, ...view.options.filter(option => option.description).map(option => ({ title: option.name, text: option.description }))],
23
+ actions, actionColumns: 1,
24
+ note: '需要确认的操作由这里的模式决定;电脑上其他会话和默认设置不受影响。' };
25
+ }
26
+ //# 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
+ }