dsh-lark-agent 0.3.3 → 0.5.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.
Files changed (51) hide show
  1. package/README.md +10 -5
  2. package/dist/bot-service.js +4 -2
  3. package/dist/bridge.d.ts +27 -1
  4. package/dist/bridge.js +333 -66
  5. package/dist/card-actions.js +3 -1
  6. package/dist/client/AgentSettings.d.ts +12 -0
  7. package/dist/client/ConnectionCard.d.ts +5 -1
  8. package/dist/client/ManagerPanel.d.ts +3 -0
  9. package/dist/client/api.d.ts +14 -2
  10. package/dist/client/index.d.ts +1 -1
  11. package/dist/client/manager-styles.d.ts +2 -0
  12. package/dist/client/use-connection.d.ts +2 -2
  13. package/dist/client.cjs +727 -258
  14. package/dist/config.d.ts +4 -0
  15. package/dist/config.js +8 -0
  16. package/dist/dsh-runtime.d.ts +3 -0
  17. package/dist/dsh-runtime.js +46 -4
  18. package/dist/lark-cli.d.ts +20 -1
  19. package/dist/lark-cli.js +168 -2
  20. package/dist/media-input.d.ts +28 -0
  21. package/dist/media-input.js +127 -0
  22. package/dist/media-scopes.d.ts +5 -0
  23. package/dist/media-scopes.js +6 -0
  24. package/dist/message.d.ts +7 -0
  25. package/dist/message.js +27 -3
  26. package/dist/onboarding/agents.d.ts +9 -0
  27. package/dist/onboarding/agents.js +100 -0
  28. package/dist/onboarding/controller.d.ts +15 -2
  29. package/dist/onboarding/controller.js +155 -14
  30. package/dist/onboarding/host.d.ts +14 -2
  31. package/dist/onboarding/host.js +49 -32
  32. package/dist/onboarding/lark-platform.d.ts +6 -0
  33. package/dist/onboarding/lark-platform.js +15 -4
  34. package/dist/onboarding/manager.d.ts +32 -0
  35. package/dist/onboarding/manager.js +129 -0
  36. package/dist/onboarding/model-check.d.ts +5 -0
  37. package/dist/onboarding/model-check.js +51 -0
  38. package/dist/onboarding/protocol.d.ts +51 -1
  39. package/dist/onboarding/settings-store.d.ts +11 -0
  40. package/dist/onboarding/settings-store.js +67 -0
  41. package/dist/replies.d.ts +16 -0
  42. package/dist/replies.js +37 -11
  43. package/dist/session-controls.d.ts +34 -0
  44. package/dist/session-controls.js +66 -0
  45. package/dist/session-store.d.ts +7 -1
  46. package/dist/session-store.js +44 -11
  47. package/dist/topic-cards.d.ts +3 -0
  48. package/dist/topic-cards.js +8 -0
  49. package/dist/topic-store.d.ts +40 -0
  50. package/dist/topic-store.js +192 -0
  51. package/package.json +1 -1
package/dist/replies.js CHANGED
@@ -32,13 +32,21 @@ function externalUrl(value) {
32
32
  return false;
33
33
  }
34
34
  }
35
- /** Turn unsupported links into visible inert text; preserve ordinary web links. */
35
+ function escapeProse(text) {
36
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37
+ }
38
+ /** Lark takes Markdown hrefs literally: HTML escaping '&' changes their query keys. */
39
+ function linkDestination(url) {
40
+ return url.replace(/&(?=(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]*;))/gi, '%26')
41
+ .replace(/["'`]/g, character => '%' + character.charCodeAt(0).toString(16).toUpperCase());
42
+ }
43
+ /** Escape prose and disable unsupported links while retaining safe HTTP(S) destinations. */
36
44
  function safeLinks(text) {
37
45
  let result = '', cursor = 0;
38
46
  while (cursor < text.length) {
39
47
  const start = text.indexOf('](', cursor);
40
48
  if (start < 0)
41
- return result + text.slice(cursor);
49
+ return result + escapeProse(text.slice(cursor));
42
50
  let end = start + 2, depth = 1;
43
51
  for (; end < text.length; end++) {
44
52
  if (text[end] === '\\') {
@@ -51,12 +59,13 @@ function safeLinks(text) {
51
59
  break;
52
60
  }
53
61
  if (end >= text.length)
54
- return result + text.slice(cursor);
62
+ return result + escapeProse(text.slice(cursor));
55
63
  const destination = text.slice(start + 2, end).trim();
56
- const url = destination.match(/^(<[^>]+>|\S+?)(?:\s+["'][\s\S]*["'])?$/)?.[1]?.replace(/^<|>$/g, '') ?? '';
57
- result += text.slice(cursor, start) + (externalUrl(url)
58
- ? text.slice(start, end + 1)
59
- : `](${destination})`);
64
+ const parts = destination.match(/^(<[^>]+>|\S+?)(\s+["'][\s\S]*["'])?$/);
65
+ const url = parts?.[1]?.replace(/^<|>$/g, '') ?? '';
66
+ result += escapeProse(text.slice(cursor, start)) + (externalUrl(url)
67
+ ? `](${linkDestination(url)}${escapeProse(parts?.[2] ?? '')})`
68
+ : `](${escapeProse(destination)})`);
60
69
  cursor = end + 1;
61
70
  }
62
71
  return result;
@@ -65,10 +74,9 @@ function prose(text) {
65
74
  let output = text
66
75
  .replace(/<(https?:\/\/[^<>\s]+)>/gi, (_match, url) => externalUrl(url) ? `[${url}](${url})` : url)
67
76
  .replace(/!\[/g, '[');
68
- output = safeLinks(output);
69
77
  output = output.replace(/^( {0,3}\[[^\]\n]+\]):\s*(<?\S+>?)(.*)$/gm, (line, label, destination) => externalUrl(destination.replace(/^<|>$/g, '')) ? line : line.replace(`${label}:`, `${label}:`));
70
- // Escape entities too, so an encoded <at> cannot become an active tag after decoding.
71
- output = output.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
78
+ // Prose still escapes entities so encoded native tags cannot become active.
79
+ output = safeLinks(output);
72
80
  return output.replace(/^[ \t]*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?[ \t]*$/gm, line => {
73
81
  const cells = line.trim().replace(/^\||\|$/g, '').split('|');
74
82
  return `| ${cells.map(() => ':---').join(' | ')} |`;
@@ -169,7 +177,8 @@ export function renderPanel(panel) {
169
177
  const metrics = (panel.metrics ?? []).filter(metric => metric.label.trim() || metric.value.trim());
170
178
  const fallbackText = safePlainText([panel.title, panel.subtitle, panel.status?.text,
171
179
  ...metrics.map(metric => [`${metric.label}:${metric.value}`, metric.note].filter(Boolean).join('\n')),
172
- ...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')), panel.note]
180
+ ...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')),
181
+ ...(panel.select?.options.map(option => `${option.label}\n/resume ${option.value}`) ?? []), panel.note]
173
182
  .filter(Boolean).join('\n\n'));
174
183
  const elements = [];
175
184
  const actions = panel.actions ?? [];
@@ -187,6 +196,23 @@ export function renderPanel(panel) {
187
196
  ], { background_style: 'grey', padding: '10px 12px 10px 12px' })),
188
197
  });
189
198
  }
199
+ if (panel.search)
200
+ elements.push({ tag: 'form', name: 'dsh_sessions_search_form', direction: 'vertical',
201
+ elements: [{ tag: 'column_set', flex_mode: 'none', horizontal_spacing: '8px', margin: '0px', columns: [column([
202
+ { tag: 'input', name: 'query', input_type: 'text', placeholder: title('搜索话题标题'), default_value: panel.search.query,
203
+ max_length: 80, width: 'fill', disabled: Boolean(panel.search.disabled) },
204
+ ]), column([
205
+ { tag: 'button', name: 'dsh_sessions_search', text: title('搜索'), type: 'default', width: 'default',
206
+ action_type: 'form_submit', form_action_type: 'submit', form_name: 'dsh_sessions_search_form',
207
+ disabled: Boolean(panel.search.disabled), behaviors: [{ type: 'callback', value: { ...panel.search.value } }] },
208
+ ], { width: 'auto' })] }] });
209
+ if (panel.select)
210
+ elements.push({ tag: 'select_static', name: 'dsh_session_picker', width: 'fill',
211
+ placeholder: title(panel.select.placeholder), disabled: Boolean(panel.select.disabled),
212
+ ...(panel.select.initialOption ? { initial_option: panel.select.initialOption } : {}),
213
+ options: panel.select.options.map(option => ({ text: title(safePlainText(option.label)), value: option.value })),
214
+ behaviors: [{ type: 'callback', value: { ...panel.select.value } }],
215
+ });
190
216
  for (const section of panel.sections) {
191
217
  if (!section.text.trim() && !section.title?.trim())
192
218
  continue;
@@ -0,0 +1,34 @@
1
+ import type { SavedSession } from './session-store.js';
2
+ import { type ReplyAction, type ReplyPanel } from './replies.js';
3
+ export interface SessionView {
4
+ page: number;
5
+ query: string;
6
+ }
7
+ export interface SessionChoice extends SavedSession {
8
+ link?: string;
9
+ }
10
+ export declare const SESSION_PAGE_SIZE = 20;
11
+ export declare const SESSION_QUERY_LENGTH = 80;
12
+ export declare function sessionQuery(value: unknown): string | undefined;
13
+ /** The query travels inside the signed command, never as an executable command fragment. */
14
+ export declare function sessionViewCommand(kind: 'page' | 'pick', view: SessionView): string;
15
+ export declare function sessionViewCommandParse(command: string): {
16
+ kind: 'page' | 'pick';
17
+ view: SessionView;
18
+ } | undefined;
19
+ export declare function sessionPage<T extends SessionChoice>(sessions: readonly T[], view: SessionView): {
20
+ items: T[];
21
+ total: number;
22
+ pages: number;
23
+ view: {
24
+ page: number;
25
+ query: string;
26
+ };
27
+ };
28
+ /** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
29
+ export declare function sessionControlPanel(sessions: readonly SessionChoice[], currentId: string | undefined, action: (label: string, command: string) => ReplyAction | undefined, options?: {
30
+ view?: SessionView;
31
+ selectedId?: string;
32
+ busy?: boolean;
33
+ status?: ReplyPanel['status'];
34
+ }): ReplyPanel;
@@ -0,0 +1,66 @@
1
+ import { plainLabel } from './replies.js';
2
+ export const SESSION_PAGE_SIZE = 20;
3
+ export const SESSION_QUERY_LENGTH = 80;
4
+ export function sessionQuery(value) {
5
+ if (typeof value !== 'string' || Array.from(value).length > SESSION_QUERY_LENGTH || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value))
6
+ return;
7
+ return value.normalize('NFC').trim();
8
+ }
9
+ /** The query travels inside the signed command, never as an executable command fragment. */
10
+ export function sessionViewCommand(kind, view) {
11
+ return `/sessions ${kind} ${view.page + 1}${view.query ? ` ${Buffer.from(view.query).toString('base64url')}` : ''}`;
12
+ }
13
+ export function sessionViewCommandParse(command) {
14
+ const match = /^\/sessions (page|pick) ([1-9][0-9]?)(?: ([A-Za-z0-9_-]{1,428}))?$/.exec(command);
15
+ if (!match)
16
+ return;
17
+ const query = match[3] ? Buffer.from(match[3], 'base64url').toString('utf8') : '';
18
+ if (sessionQuery(query) !== query || (match[3] && Buffer.from(query).toString('base64url') !== match[3]))
19
+ return;
20
+ return { kind: match[1], view: { page: Number(match[2]) - 1, query } };
21
+ }
22
+ export function sessionPage(sessions, view) {
23
+ const query = view.query.normalize('NFC').toLocaleLowerCase();
24
+ const matches = sessions.filter(session => session.title.normalize('NFC').toLocaleLowerCase().includes(query))
25
+ .sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
26
+ const pages = Math.max(1, Math.ceil(matches.length / SESSION_PAGE_SIZE));
27
+ const page = Math.max(0, Math.min(view.page, pages - 1));
28
+ return { items: matches.slice(page * SESSION_PAGE_SIZE, (page + 1) * SESSION_PAGE_SIZE), total: matches.length, pages, view: { ...view, page } };
29
+ }
30
+ /** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
31
+ export function sessionControlPanel(sessions, currentId, action, options = {}) {
32
+ const page = sessionPage(sessions, options.view ?? { page: 0, query: '' });
33
+ const selected = sessions.find(session => session.id === (options.selectedId ?? currentId));
34
+ const selectedVisible = page.items.some(session => session.id === selected?.id);
35
+ const pick = action('选择话题', sessionViewCommand('pick', page.view));
36
+ const search = action('搜索', '/sessions search');
37
+ const actions = [
38
+ ...(page.view.page > 0 ? [action('上一页', sessionViewCommand('page', { ...page.view, page: page.view.page - 1 }))] : []),
39
+ ...(page.view.page + 1 < page.pages ? [action('下一页', sessionViewCommand('page', { ...page.view, page: page.view.page + 1 }))] : []),
40
+ action('新建话题', '/new'),
41
+ ].filter((item) => Boolean(item));
42
+ const duplicates = new Map();
43
+ const dateFormat = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
44
+ for (const session of page.items) {
45
+ const title = plainLabel(session.title, 30);
46
+ duplicates.set(title, (duplicates.get(title) ?? 0) + 1);
47
+ }
48
+ const count = page.view.query ? `找到 ${page.total} 个话题 · 共 ${sessions.length} 个` : `共 ${sessions.length} 个话题 · 最近活跃优先`;
49
+ return { title: '以前的对话', summary: page.view.query ? `搜索历史话题:${page.total} 个结果,可选择话题继续` : `浏览 ${sessions.length} 个历史话题,选择后打开继续聊天`,
50
+ subtitle: count, ...(options.status ? { status: options.status, tone: options.status.tone } : {}),
51
+ ...(search ? { search: { query: page.view.query, value: search.value, disabled: options.busy } } : {}),
52
+ ...(pick && page.items.length ? { select: { placeholder: '选择一个话题继续聊天', value: pick.value, disabled: options.busy,
53
+ ...(selectedVisible ? { initialOption: selected.id } : {}),
54
+ options: page.items.map(session => {
55
+ const title = plainLabel(session.title, 30);
56
+ const date = dateFormat.format(session.updatedAt);
57
+ return { value: session.id, label: `${title}${Array.from(session.title).length > 30 ? '…' : ''} · ${date}${duplicates.get(title) > 1 ? ` · ${session.id.slice(-4)}` : ''}` };
58
+ }) } } : {}),
59
+ sections: !page.total ? [{ text: sessions.length ? '没有找到匹配的话题。换个关键词,或清空搜索查看全部。' : '还没有历史话题。直接发消息,就会开始新的对话。' }]
60
+ : !pick ? page.items.map(session => ({ title: plainLabel(session.title, 60), text: `/resume ${session.id}` }))
61
+ : selected ? [{ ...(!selectedVisible ? { title: plainLabel(selected.title, 60) } : {}), text: selected.link ? `[打开话题继续](${selected.link})`
62
+ : selected.id === currentId ? '当前正在聊的话题' : '话题入口暂不可用,请在聊天中找到对应话题后继续。' }] : [],
63
+ actions, actionColumns: actions.length === 1 ? 1 : actions.length === 2 ? 2 : 3,
64
+ note: `第 ${page.view.page + 1} / ${page.pages} 页${page.view.query ? ' · 清空搜索可查看全部话题' : ''}${!pick ? '\n文字操作:/sessions detail;/resume <ID>' : ''}`, };
65
+ }
66
+ //# sourceMappingURL=session-controls.js.map
@@ -1,6 +1,8 @@
1
1
  export interface SessionContext {
2
2
  ownerKey: string;
3
3
  conversationKey: string;
4
+ /** A topic may point at one historical session without changing its siblings' active selection. */
5
+ sessionId?: string;
4
6
  }
5
7
  export interface SavedSession {
6
8
  id: string;
@@ -8,6 +10,8 @@ export interface SavedSession {
8
10
  title: string;
9
11
  createdAt: number;
10
12
  updatedAt: number;
13
+ /** Workspace captured when this native session was created. */
14
+ cwd?: string;
11
15
  }
12
16
  export type SessionStoreErrorCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'NO_CURRENT' | 'CONFLICT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE';
13
17
  /** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
@@ -33,7 +37,9 @@ export declare class SessionStore {
33
37
  private mutate;
34
38
  current(context: SessionContext): Promise<SavedSession | undefined>;
35
39
  list(context: SessionContext): Promise<SavedSession[]>;
36
- add(context: SessionContext, nativeId: string, title?: string): Promise<SavedSession>;
40
+ add(context: SessionContext, nativeId: string, title?: string, cwd?: string): Promise<SavedSession>;
41
+ /** Under the bot's state lock, capture legacy rows before its workspace changes. */
42
+ pinWorkspace(cwd: string): Promise<void>;
37
43
  activate(context: SessionContext, id: string): Promise<SavedSession>;
38
44
  /** Replace only a confirmed native binding, preserving the user's stable topic. */
39
45
  rebindNative(context: SessionContext, id: string, expectedNativeId: string, newNativeId: string): Promise<SavedSession>;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { join, isAbsolute } from 'node:path';
4
4
  /** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
5
5
  export class SessionStoreError extends Error {
6
6
  code;
@@ -30,7 +30,8 @@ function validKey(value) {
30
30
  && value === value.trim() && !CONTROLS.test(value);
31
31
  }
32
32
  function validateContext(value) {
33
- if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey))
33
+ if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey)
34
+ || (value.sessionId !== undefined && (typeof value.sessionId !== 'string' || !SESSION_ID.test(value.sessionId))))
34
35
  throw new SessionStoreError('INVALID_INPUT');
35
36
  }
36
37
  function titleText(value) {
@@ -79,11 +80,14 @@ function parseDocument(value) {
79
80
  || session.updatedAt < session.createdAt)
80
81
  throw new Error();
81
82
  const title = titleText(session.title);
83
+ if (session.cwd !== undefined && (typeof session.cwd !== 'string' || !isAbsolute(session.cwd) || CONTROLS.test(session.cwd)))
84
+ throw new Error();
82
85
  if (title !== session.title || sessionIds.has(session.id) || nativeIds.has(session.nativeId.toLowerCase()))
83
86
  throw new Error();
84
87
  sessionIds.add(session.id);
85
88
  nativeIds.add(session.nativeId.toLowerCase());
86
- return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt };
89
+ return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt,
90
+ ...(session.cwd === undefined ? {} : { cwd: session.cwd }) };
87
91
  });
88
92
  if (entry.activeId !== null && (typeof entry.activeId !== 'string' || !sessions.some(session => session.id === entry.activeId)))
89
93
  throw new Error();
@@ -175,7 +179,7 @@ export class SessionStore {
175
179
  }
176
180
  mutate(context, update) {
177
181
  // Snapshot caller-owned identity before an earlier queued mutation awaits I/O.
178
- const identity = { ownerKey: context?.ownerKey, conversationKey: context?.conversationKey };
182
+ const identity = { ...context };
179
183
  const job = this.tail.then(async () => {
180
184
  this.assertAvailable();
181
185
  validateContext(identity);
@@ -196,7 +200,9 @@ export class SessionStore {
196
200
  await this.tail;
197
201
  this.assertAvailable();
198
202
  const entry = this.locate(this.document, identity);
199
- const current = entry?.sessions.find(session => session.id === entry.activeId);
203
+ const current = entry?.sessions.find(session => session.id === (identity.sessionId ?? entry.activeId));
204
+ if (identity.sessionId && !current)
205
+ throw new SessionStoreError('NOT_FOUND');
200
206
  return current && { ...current };
201
207
  }
202
208
  async list(context) {
@@ -207,11 +213,15 @@ export class SessionStore {
207
213
  return (this.locate(this.document, identity)?.sessions ?? [])
208
214
  .map(session => ({ ...session })).sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
209
215
  }
210
- add(context, nativeId, title = '新会话') {
216
+ add(context, nativeId, title = '新会话', cwd) {
211
217
  const identity = { ...context };
212
218
  return this.mutate(identity, draft => {
219
+ if (identity.sessionId !== undefined)
220
+ throw new SessionStoreError('INVALID_INPUT');
213
221
  if (!NATIVE_ID.test(nativeId))
214
222
  throw new SessionStoreError('INVALID_INPUT');
223
+ if (cwd !== undefined && (!isAbsolute(cwd) || CONTROLS.test(cwd)))
224
+ throw new SessionStoreError('INVALID_INPUT');
215
225
  const safeTitle = titleText(title);
216
226
  let entry = this.locate(draft, identity);
217
227
  const owned = draft.contexts.filter(candidate => candidate.ownerKey === identity.ownerKey);
@@ -226,9 +236,9 @@ export class SessionStore {
226
236
  id = randomBytes(6).toString('hex');
227
237
  } while (usedIds.has(id));
228
238
  const time = this.nextTimestamp(draft);
229
- const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time };
239
+ const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time, ...(cwd === undefined ? {} : { cwd }) };
230
240
  if (!entry) {
231
- entry = { ...identity, activeId: null, sessions: [] };
241
+ entry = { ownerKey: identity.ownerKey, conversationKey: identity.conversationKey, activeId: null, sessions: [] };
232
242
  draft.contexts.push(entry);
233
243
  }
234
244
  entry.sessions.push(session);
@@ -236,6 +246,29 @@ export class SessionStore {
236
246
  return { value: session, changed: true };
237
247
  });
238
248
  }
249
+ /** Under the bot's state lock, capture legacy rows before its workspace changes. */
250
+ pinWorkspace(cwd) {
251
+ const job = this.tail.then(async () => {
252
+ this.assertAvailable();
253
+ if (!isAbsolute(cwd) || CONTROLS.test(cwd))
254
+ throw new SessionStoreError('INVALID_INPUT');
255
+ const draft = structuredClone(this.document);
256
+ let changed = false;
257
+ for (const context of draft.contexts)
258
+ for (const session of context.sessions) {
259
+ if (session.cwd === undefined) {
260
+ session.cwd = cwd;
261
+ changed = true;
262
+ }
263
+ }
264
+ if (changed) {
265
+ await this.persist(draft);
266
+ this.document = draft;
267
+ }
268
+ });
269
+ this.tail = job.catch(() => undefined);
270
+ return job;
271
+ }
239
272
  activate(context, id) {
240
273
  const identity = { ...context };
241
274
  return this.mutate(identity, draft => {
@@ -275,8 +308,8 @@ export class SessionStore {
275
308
  const identity = { ...context };
276
309
  return this.mutate(identity, draft => {
277
310
  const entry = this.locate(draft, identity);
278
- const changed = Boolean(entry?.activeId);
279
- if (entry)
311
+ const changed = Boolean(entry?.activeId && (!identity.sessionId || identity.sessionId === entry.activeId));
312
+ if (entry && changed)
280
313
  entry.activeId = null;
281
314
  return { value: undefined, changed };
282
315
  });
@@ -286,7 +319,7 @@ export class SessionStore {
286
319
  return this.mutate(identity, draft => {
287
320
  const safeTitle = titleText(title);
288
321
  const entry = this.locate(draft, identity);
289
- const session = entry?.sessions.find(candidate => candidate.id === entry.activeId);
322
+ const session = entry?.sessions.find(candidate => candidate.id === (identity.sessionId ?? entry.activeId));
290
323
  if (!session)
291
324
  throw new SessionStoreError('NO_CURRENT');
292
325
  session.title = safeTitle;
@@ -0,0 +1,3 @@
1
+ import type { ReplyPanel } from './replies.js';
2
+ /** The entry itself owns the native thread; it needs no separate navigation confirmation. */
3
+ export declare function topicEntryPanel(title: string, chatType: 'p2p' | 'group'): ReplyPanel;
@@ -0,0 +1,8 @@
1
+ /** The entry itself owns the native thread; it needs no separate navigation confirmation. */
2
+ export function topicEntryPanel(title, chatType) {
3
+ return { title, summary: `${title},在话题内继续对话`,
4
+ sections: [{ text: '在下方点「回复话题」,继续聊天。' }],
5
+ ...(chatType === 'group' ? { note: '群聊中每条消息仍需 @机器人。' } : {}),
6
+ };
7
+ }
8
+ //# sourceMappingURL=topic-cards.js.map
@@ -0,0 +1,40 @@
1
+ export interface TopicScope {
2
+ ownerKey: string;
3
+ chatKey: string;
4
+ }
5
+ export interface TopicReceipt {
6
+ threadId: string;
7
+ messageId: string;
8
+ link?: string;
9
+ }
10
+ /** Keys come exclusively from verified events and confirmed bot receipts. No message text is stored. */
11
+ export interface TopicBinding extends TopicScope, TopicReceipt {
12
+ laneKey: string;
13
+ conversationKey: string;
14
+ sessionId?: string;
15
+ groupAccessKey?: string;
16
+ }
17
+ type ErrorCode = 'INVALID_INPUT' | 'CORRUPT' | 'CONFLICT' | 'CAPACITY' | 'WRITE_FAILED' | 'UNAVAILABLE';
18
+ export declare class TopicStoreError extends Error {
19
+ readonly code: ErrorCode;
20
+ constructor(code: ErrorCode);
21
+ }
22
+ /** Use only links returned by Lark's read API/CLI enrichment; never synthesize a deep link. */
23
+ export declare function topicLink(value: unknown): string | undefined;
24
+ /** The state lock owns this store. Ambiguous publication poisons it until restart. */
25
+ export declare class TopicStore {
26
+ private readonly directory;
27
+ private entries;
28
+ private tail;
29
+ private unavailable;
30
+ private constructor();
31
+ static open(directory: string): Promise<TopicStore>;
32
+ private validate;
33
+ private assertAvailable;
34
+ list(scope: TopicScope): Promise<TopicBinding[]>;
35
+ get(scope: TopicScope, threadId: string): Promise<TopicBinding | undefined>;
36
+ occupied(chatKey: string, threadId: string): Promise<boolean>;
37
+ record(binding: TopicBinding): Promise<TopicBinding>;
38
+ private persist;
39
+ }
40
+ export {};
@@ -0,0 +1,192 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, mkdir, open, rename, unlink } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ export class TopicStoreError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super({ INVALID_INPUT: '话题参数无效。', CORRUPT: '话题目录损坏,请管理员检查备份;已有记录未被清空。',
9
+ CONFLICT: '话题绑定已变化,请重新打开历史对话。', CAPACITY: '话题数量已达到上限,已有话题会保留。',
10
+ WRITE_FAILED: '话题目录保存失败,请稍后重试。', UNAVAILABLE: '话题目录的保存结果未能确认,请管理员检查后重新启动。' }[code]);
11
+ this.code = code;
12
+ this.name = 'TopicStoreError';
13
+ }
14
+ }
15
+ const KEY = /^[a-f0-9]{64}$/;
16
+ const MESSAGE = /^om_[A-Za-z0-9_-]{1,256}$/;
17
+ const THREAD = /^(?:om|omt)_[A-Za-z0-9_-]{1,256}$/;
18
+ const MAX_TOPICS = 10_000;
19
+ const MAX_OWNER_TOPICS = 1000;
20
+ const MAX_BYTES = 8 * 1024 * 1024;
21
+ /** Use only links returned by Lark's read API/CLI enrichment; never synthesize a deep link. */
22
+ export function topicLink(value) {
23
+ if (typeof value !== 'string' || value.length > 4096)
24
+ return;
25
+ try {
26
+ const url = new URL(value);
27
+ if (url.protocol === 'https:' && !url.username && !url.password && !url.port
28
+ && ['applink.feishu.cn', 'applink.larksuite.com'].includes(url.hostname)
29
+ && /^\/client\/(?:thread\/open|chat\/open|message\/link\/open)$/.test(url.pathname))
30
+ return url.href;
31
+ }
32
+ catch { /* An unavailable link does not invalidate a confirmed topic. */ }
33
+ }
34
+ function validScope(scope) {
35
+ return Boolean(scope && typeof scope.ownerKey === 'string' && KEY.test(scope.ownerKey)
36
+ && typeof scope.chatKey === 'string' && KEY.test(scope.chatKey));
37
+ }
38
+ function parseBinding(value) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value))
40
+ throw new TopicStoreError('INVALID_INPUT');
41
+ const item = value;
42
+ if (!validScope(item) || typeof item.laneKey !== 'string' || !KEY.test(item.laneKey)
43
+ || typeof item.conversationKey !== 'string' || !KEY.test(item.conversationKey)
44
+ || typeof item.threadId !== 'string' || !THREAD.test(item.threadId)
45
+ || typeof item.messageId !== 'string' || !MESSAGE.test(item.messageId)
46
+ || (item.sessionId !== undefined && (typeof item.sessionId !== 'string' || !/^[a-f0-9]{12}$/.test(item.sessionId)))
47
+ || (item.groupAccessKey !== undefined && (typeof item.groupAccessKey !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(item.groupAccessKey)))
48
+ || (item.link !== undefined && topicLink(item.link) !== item.link))
49
+ throw new TopicStoreError('INVALID_INPUT');
50
+ return { ownerKey: item.ownerKey, chatKey: item.chatKey, laneKey: item.laneKey, conversationKey: item.conversationKey,
51
+ threadId: item.threadId, messageId: item.messageId,
52
+ ...(item.sessionId === undefined ? {} : { sessionId: item.sessionId }),
53
+ ...(item.groupAccessKey === undefined ? {} : { groupAccessKey: item.groupAccessKey }),
54
+ ...(item.link === undefined ? {} : { link: item.link }) };
55
+ }
56
+ function sameScope(a, b) { return a.ownerKey === b.ownerKey && a.chatKey === b.chatKey; }
57
+ /** The state lock owns this store. Ambiguous publication poisons it until restart. */
58
+ export class TopicStore {
59
+ directory;
60
+ entries = [];
61
+ tail = Promise.resolve();
62
+ unavailable = false;
63
+ constructor(directory) {
64
+ this.directory = directory;
65
+ }
66
+ static async open(directory) {
67
+ await mkdir(directory, { recursive: true, mode: 0o700 });
68
+ await chmod(directory, 0o700);
69
+ const store = new TopicStore(directory);
70
+ let handle;
71
+ try {
72
+ handle = await open(join(directory, 'topics.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ const info = await handle.stat();
74
+ if (!info.isFile() || info.size > MAX_BYTES)
75
+ throw new Error();
76
+ const bytes = await handle.readFile();
77
+ if (bytes.length > MAX_BYTES)
78
+ throw new Error();
79
+ const data = JSON.parse(bytes.toString('utf8'));
80
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !('version' in data) || data.version !== 1
81
+ || !('topics' in data) || !Array.isArray(data.topics) || data.topics.length > MAX_TOPICS)
82
+ throw new Error();
83
+ store.entries = data.topics.map(parseBinding);
84
+ store.validate(store.entries);
85
+ await handle.chmod(0o600);
86
+ }
87
+ catch (error) {
88
+ if (error.code !== 'ENOENT')
89
+ throw new TopicStoreError('CORRUPT');
90
+ }
91
+ finally {
92
+ await handle?.close();
93
+ }
94
+ return store;
95
+ }
96
+ validate(entries) {
97
+ const threads = new Set(), lanes = new Set(), sessions = new Set(), owners = new Map();
98
+ for (const item of entries) {
99
+ const thread = JSON.stringify([item.chatKey, item.threadId]);
100
+ if (threads.has(thread) || lanes.has(item.laneKey) || (item.sessionId && sessions.has(item.sessionId)))
101
+ throw new TopicStoreError('CONFLICT');
102
+ threads.add(thread);
103
+ lanes.add(item.laneKey);
104
+ if (item.sessionId)
105
+ sessions.add(item.sessionId);
106
+ const count = (owners.get(item.ownerKey) ?? 0) + 1;
107
+ owners.set(item.ownerKey, count);
108
+ if (count > MAX_OWNER_TOPICS || entries.length > MAX_TOPICS)
109
+ throw new TopicStoreError('CAPACITY');
110
+ }
111
+ }
112
+ assertAvailable() { if (this.unavailable)
113
+ throw new TopicStoreError('UNAVAILABLE'); }
114
+ async list(scope) {
115
+ if (!validScope(scope))
116
+ throw new TopicStoreError('INVALID_INPUT');
117
+ const identity = { ...scope };
118
+ await this.tail;
119
+ this.assertAvailable();
120
+ return this.entries.filter(item => sameScope(identity, item)).map(item => ({ ...item }));
121
+ }
122
+ async get(scope, threadId) {
123
+ return (await this.list(scope)).find(item => item.threadId === threadId);
124
+ }
125
+ async occupied(chatKey, threadId) {
126
+ if (!KEY.test(chatKey) || !THREAD.test(threadId))
127
+ throw new TopicStoreError('INVALID_INPUT');
128
+ await this.tail;
129
+ this.assertAvailable();
130
+ return this.entries.some(item => item.chatKey === chatKey && item.threadId === threadId);
131
+ }
132
+ record(binding) {
133
+ // Snapshot the caller before queued I/O, and retain only the schema's fields.
134
+ const next = parseBinding(binding);
135
+ const job = this.tail.then(async () => {
136
+ this.assertAvailable();
137
+ const existing = this.entries.find(item => item.chatKey === next.chatKey && item.threadId === next.threadId);
138
+ if (existing) {
139
+ if (!sameScope(existing, next) || existing.laneKey !== next.laneKey || existing.conversationKey !== next.conversationKey
140
+ || existing.groupAccessKey !== next.groupAccessKey || (existing.sessionId && existing.sessionId !== next.sessionId))
141
+ throw new TopicStoreError('CONFLICT');
142
+ // A later event must not move the topic's stable anchor or discard its selected session/link.
143
+ next.messageId = existing.messageId;
144
+ next.sessionId ??= existing.sessionId;
145
+ next.link ??= existing.link;
146
+ if (JSON.stringify(existing) === JSON.stringify(next))
147
+ return { ...existing };
148
+ }
149
+ const entries = this.entries.filter(item => item !== existing).concat(next);
150
+ this.validate(entries);
151
+ await this.persist(entries);
152
+ this.entries = entries;
153
+ return { ...next };
154
+ });
155
+ this.tail = job.then(() => undefined, () => undefined);
156
+ return job;
157
+ }
158
+ async persist(topics) {
159
+ const contents = JSON.stringify({ version: 1, topics }) + '\n';
160
+ // Long read-enriched links count toward the same bound used when reopening.
161
+ if (Buffer.byteLength(contents) > MAX_BYTES)
162
+ throw new TopicStoreError('CAPACITY');
163
+ const temporary = join(this.directory, `topics.${randomUUID()}.tmp`);
164
+ let publicationAttempted = false;
165
+ try {
166
+ const handle = await open(temporary, 'wx', 0o600);
167
+ try {
168
+ await handle.writeFile(contents);
169
+ await handle.sync();
170
+ }
171
+ finally {
172
+ await handle.close();
173
+ }
174
+ publicationAttempted = true;
175
+ await rename(temporary, join(this.directory, 'topics.json'));
176
+ const directory = await open(this.directory, 'r');
177
+ try {
178
+ await directory.sync();
179
+ }
180
+ finally {
181
+ await directory.close();
182
+ }
183
+ }
184
+ catch {
185
+ if (publicationAttempted)
186
+ this.unavailable = true;
187
+ await unlink(temporary).catch(() => undefined);
188
+ throw new TopicStoreError(publicationAttempted ? 'UNAVAILABLE' : 'WRITE_FAILED');
189
+ }
190
+ }
191
+ }
192
+ //# sourceMappingURL=topic-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lark-agent",
3
- "version": "0.3.3",
3
+ "version": "0.5.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",