@restregular/dsh-notepad 0.3.0 → 0.4.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.
package/index.js CHANGED
@@ -22,6 +22,7 @@ import path from 'node:path';
22
22
  import {
23
23
  IMAGE_TYPES,
24
24
  MAX_IMAGE_BYTES,
25
+ deleteStoredImage,
25
26
  saveClipboardImage,
26
27
  } from './lib/image-store.js';
27
28
  import {
@@ -29,6 +30,7 @@ import {
29
30
  NOTE_VERSION,
30
31
  NoteStoreError,
31
32
  readNote,
33
+ unsentCount,
32
34
  writeNote,
33
35
  } from './lib/note-store.js';
34
36
 
@@ -36,7 +38,7 @@ import {
36
38
  const ROUTE = '/dsh-notepad/api';
37
39
 
38
40
  /** Plugin version mirrored in package.json. */
39
- const VERSION = '0.3.0';
41
+ const VERSION = '0.4.0';
40
42
 
41
43
  /** Request body cap for note writes. */
42
44
  const BODY_LIMIT = 1024 * 1024;
@@ -44,6 +46,24 @@ const BODY_LIMIT = 1024 * 1024;
44
46
  /** Request body cap for base64 image uploads (decoded cap + base64 overhead). */
45
47
  const IMAGE_BODY_LIMIT = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 64 * 1024;
46
48
 
49
+ /** Longest segment accepted by the AI optimizer. */
50
+ const MAX_OPTIMIZE_CHARS = 60_000;
51
+
52
+ /** End-to-end deadline for one AI optimization. */
53
+ const OPTIMIZE_TIMEOUT_MS = 120_000;
54
+
55
+ /** Default output cap for one optimized segment. */
56
+ const OPTIMIZE_MAX_TOKENS = 4096;
57
+
58
+ /** Shared instruction for the optional writing assistant. */
59
+ const OPTIMIZE_SYSTEM = [
60
+ 'You are a writing assistant for a developer notepad.',
61
+ 'Rewrite the user\'s note so it is clearer, more concise, and better organized,',
62
+ 'while preserving its original language, meaning, technical facts, Markdown,',
63
+ 'code, file paths, and image links. Never invent facts or drop information.',
64
+ 'Return only the rewritten note text: no preamble, no explanation, and no code fence.',
65
+ ].join(' ');
66
+
47
67
  // ---------------------------------------------------------------------------
48
68
  // HTTP helpers
49
69
  // ---------------------------------------------------------------------------
@@ -174,6 +194,131 @@ async function isKnownRoot(ctx, root, sessionId) {
174
194
  return false;
175
195
  }
176
196
 
197
+ /**
198
+ * Count pending segments for every registered workspace. The sidebar badge
199
+ * only needs counts, so this route never returns note text or image paths.
200
+ * @param {import('@deepseek-ai/cordis').Context} ctx
201
+ * @returns {Promise<Array<{id: string, path: string, title: string, count: number}>>}
202
+ */
203
+ async function listUnsentCounts(ctx) {
204
+ const registry = ctx.get('workspaceRegistry');
205
+ const list = registry !== undefined && typeof registry.list === 'function' ? registry.list() : [];
206
+ if (!Array.isArray(list)) return [];
207
+ const out = [];
208
+ for (const workspace of list) {
209
+ const root = typeof workspace?.path === 'string' ? workspace.path : '';
210
+ if (root === '') continue;
211
+ let count = 0;
212
+ try {
213
+ count = unsentCount(await readNote(root));
214
+ } catch {
215
+ // A workspace directory may have disappeared; keep the other badges.
216
+ count = 0;
217
+ }
218
+ out.push({
219
+ id: String(workspace.id ?? workspace.workspaceId ?? ''),
220
+ path: root,
221
+ title: typeof workspace.title === 'string' ? workspace.title : '',
222
+ count,
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * Strip a surrounding Markdown code fence the model may add despite the
230
+ * instruction, then trim. Inner code fences survive.
231
+ * @param {string} value
232
+ * @returns {string}
233
+ */
234
+ function cleanOptimizedText(value) {
235
+ let text = String(value ?? '').replace(/^\uFEFF/u, '').trim();
236
+ const fenced = /^```[^\n]*\r?\n([\s\S]*?)\r?\n```$/u.exec(text);
237
+ if (fenced !== null) text = fenced[1].trim();
238
+ return text;
239
+ }
240
+
241
+ /**
242
+ * Ask the currently selected default model to improve one note segment.
243
+ * The LLM service is optional: a deployment without one reports a stable
244
+ * `unavailable` code instead of failing the whole notepad.
245
+ * @param {import('@deepseek-ai/cordis').Context} ctx
246
+ * @param {string} text
247
+ * @param {string} title
248
+ * @returns {Promise<string>}
249
+ */
250
+ async function optimizeText(ctx, text, title) {
251
+ const llm = ctx.get('llm');
252
+ if (llm === undefined || llm === null || typeof llm.prepareCall !== 'function'
253
+ || typeof llm.resolveCallConfig !== 'function') {
254
+ throw new NoteStoreError('unavailable', 'The AI model service is not available.');
255
+ }
256
+ const defaultModel = ctx.get('agentDefaultModel');
257
+ if (defaultModel === undefined || defaultModel === null || typeof defaultModel.currentSelection !== 'function') {
258
+ throw new NoteStoreError('unavailable', 'No default model is configured.');
259
+ }
260
+ let selection;
261
+ try {
262
+ selection = defaultModel.currentSelection();
263
+ } catch (error) {
264
+ throw new NoteStoreError('unavailable', `No default model is configured: ${error?.message ?? error}`);
265
+ }
266
+ if (selection === undefined || selection === null
267
+ || typeof selection.provider !== 'string' || selection.provider === ''
268
+ || typeof selection.model !== 'string' || selection.model === '') {
269
+ throw new NoteStoreError('unavailable', 'No default model is configured.');
270
+ }
271
+ const signal = AbortSignal.timeout(OPTIMIZE_TIMEOUT_MS);
272
+ const maxTokens = Math.min(8192, Math.max(1024, Math.ceil(text.length / 2)));
273
+ let base;
274
+ try {
275
+ base = await llm.resolveCallConfig({
276
+ provider: selection.provider,
277
+ model: selection.model,
278
+ maxTokens,
279
+ });
280
+ } catch (error) {
281
+ throw new NoteStoreError('llm-failed', `Could not prepare the model call: ${error?.message ?? error}`);
282
+ }
283
+ if (typeof base !== 'object' || base === null) {
284
+ throw new NoteStoreError('llm-failed', 'The model call configuration is empty.');
285
+ }
286
+ const heading = title.trim() === '' ? '' : `Card title: ${title.trim()}\n\n`;
287
+ const prompt = `${heading}Rewrite this note:\n\n${text}`;
288
+ let output = '';
289
+ try {
290
+ const prepared = await llm.prepareCall(base, signal);
291
+ for await (const chunk of prepared.stream({
292
+ ...base,
293
+ system: OPTIMIZE_SYSTEM,
294
+ messages: [{
295
+ role: 'user',
296
+ content: [{ type: 'text', text: prompt }],
297
+ }],
298
+ signal,
299
+ })) {
300
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') output += chunk.text;
301
+ else if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') {
302
+ const failure = chunk.reason.failure;
303
+ throw new NoteStoreError('llm-failed', `The model call failed: ${failure?.message ?? 'unknown error'}`);
304
+ } else if (chunk?.type === 'finish' && chunk.reason?.kind === 'aborted') {
305
+ throw new NoteStoreError('timeout', 'The model did not answer in time.');
306
+ }
307
+ }
308
+ } catch (error) {
309
+ if (error instanceof NoteStoreError) throw error;
310
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
311
+ throw new NoteStoreError('timeout', 'The model did not answer in time.');
312
+ }
313
+ throw new NoteStoreError('llm-failed', `The model call failed: ${error?.message ?? error}`);
314
+ }
315
+ const cleaned = cleanOptimizedText(output);
316
+ if (cleaned === '') {
317
+ throw new NoteStoreError('llm-failed', 'The model returned no text.');
318
+ }
319
+ return cleaned;
320
+ }
321
+
177
322
  // ---------------------------------------------------------------------------
178
323
  // Route
179
324
  // ---------------------------------------------------------------------------
@@ -190,9 +335,12 @@ export async function handleRequest(ctx, req, res) {
190
335
  } catch (error) {
191
336
  if (error instanceof NoteStoreError) {
192
337
  let status = 400;
193
- if (error.code === 'unknown-workspace' || error.code === 'bad-root') status = 403;
338
+ if (error.code === 'unknown-workspace' || error.code === 'bad-root' || error.code === 'not-owned') status = 403;
194
339
  else if (error.code === 'too-large') status = 413;
195
340
  else if (error.code === 'unsupported-type') status = 415;
341
+ else if (error.code === 'unavailable') status = 503;
342
+ else if (error.code === 'timeout') status = 504;
343
+ else if (error.code === 'llm-failed') status = 502;
196
344
  sendJson(res, { ok: false, code: error.code, error: error.message }, status);
197
345
  return;
198
346
  }
@@ -230,18 +378,55 @@ async function dispatchRequest(ctx, req, res) {
230
378
  return;
231
379
  }
232
380
  if (action === 'image') {
381
+ if (req.method === 'POST') {
382
+ const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
383
+ const root = await resolveRequestRoot(ctx, body);
384
+ const image = await saveClipboardImage(root, {
385
+ name: body.name,
386
+ mime: body.mime,
387
+ data: body.data,
388
+ });
389
+ sendJson(res, { ok: true, root, ...image });
390
+ return;
391
+ }
392
+ if (req.method === 'DELETE') {
393
+ const body = await readJsonBody(req);
394
+ const root = await resolveRequestRoot(ctx, body);
395
+ const result = await deleteStoredImage(root, body.path);
396
+ sendJson(res, { ok: true, root, ...result });
397
+ return;
398
+ }
399
+ sendJson(res, { ok: false, code: 'method', error: 'Use POST or DELETE.' }, 405);
400
+ return;
401
+ }
402
+ if (action === 'unsent') {
403
+ if (req.method !== 'GET') {
404
+ sendJson(res, { ok: false, code: 'method', error: 'Use GET.' }, 405);
405
+ return;
406
+ }
407
+ const workspaces = await listUnsentCounts(ctx);
408
+ sendJson(res, { ok: true, workspaces });
409
+ return;
410
+ }
411
+ if (action === 'optimize') {
233
412
  if (req.method !== 'POST') {
234
413
  sendJson(res, { ok: false, code: 'method', error: 'Use POST.' }, 405);
235
414
  return;
236
415
  }
237
- const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
416
+ const body = await readJsonBody(req);
238
417
  const root = await resolveRequestRoot(ctx, body);
239
- const image = await saveClipboardImage(root, {
240
- name: body.name,
241
- mime: body.mime,
242
- data: body.data,
243
- });
244
- sendJson(res, { ok: true, root, ...image });
418
+ const text = typeof body.text === 'string' ? body.text : '';
419
+ if (text.trim() === '') {
420
+ throw new NoteStoreError('bad-input', 'There is no text to optimize.');
421
+ }
422
+ if (text.length > MAX_OPTIMIZE_CHARS) {
423
+ throw new NoteStoreError(
424
+ 'too-large',
425
+ `Segment is too large to optimize (${text.length} characters; limit ${MAX_OPTIMIZE_CHARS}).`,
426
+ );
427
+ }
428
+ const optimized = await optimizeText(ctx, text, typeof body.title === 'string' ? body.title : '');
429
+ sendJson(res, { ok: true, root, text: optimized });
245
430
  return;
246
431
  }
247
432
  if (action !== 'note') {
@@ -129,6 +129,55 @@ function displayName(name) {
129
129
  return cleaned === '' ? 'image' : cleaned;
130
130
  }
131
131
 
132
+ /**
133
+ * Absolute plugin-owned image directory for one workspace.
134
+ * @param {string} root workspace root
135
+ * @returns {string}
136
+ */
137
+ export function imageDirOf(root) {
138
+ return path.join(root, ...IMAGE_DIR.split('/'));
139
+ }
140
+
141
+ /**
142
+ * Delete one image previously stored under the plugin directory.
143
+ *
144
+ * The client may only remove files this plugin owns: the resolved target must
145
+ * be a regular file inside {@link IMAGE_DIR}. Paths outside that directory are
146
+ * refused instead of being passed to `fs.rm`.
147
+ *
148
+ * @param {string} root workspace root
149
+ * @param {unknown} pathValue workspace-relative or absolute image path
150
+ * @returns {Promise<{deleted: boolean}>}
151
+ */
152
+ export async function deleteStoredImage(root, pathValue) {
153
+ if (typeof pathValue !== 'string' || pathValue.trim() === '') {
154
+ throw new NoteStoreError('bad-input', 'Image path is required.');
155
+ }
156
+ const dir = imageDirOf(root);
157
+ const resolved = path.resolve(root, pathValue.replace(/\\/gu, '/'));
158
+ const relative = path.relative(dir, resolved);
159
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
160
+ throw new NoteStoreError('not-owned', 'Only images stored by the notepad can be deleted.');
161
+ }
162
+ let stat;
163
+ try {
164
+ stat = await fs.lstat(resolved);
165
+ } catch (error) {
166
+ if (error?.code === 'ENOENT') return { deleted: false };
167
+ throw error;
168
+ }
169
+ if (!stat.isFile()) {
170
+ throw new NoteStoreError('not-owned', 'Only regular image files can be deleted.');
171
+ }
172
+ try {
173
+ await fs.unlink(resolved);
174
+ } catch (error) {
175
+ if (error?.code === 'ENOENT') return { deleted: false };
176
+ throw error;
177
+ }
178
+ return { deleted: true };
179
+ }
180
+
132
181
  /**
133
182
  * Save one pasted image under the plugin directory.
134
183
  *
@@ -155,7 +204,7 @@ export async function saveClipboardImage(root, input) {
155
204
  const extension = IMAGE_TYPES[declared];
156
205
  const name = displayName(input?.name);
157
206
  const stem = fileStem(name);
158
- const dir = path.join(root, ...IMAGE_DIR.split('/'));
207
+ const dir = imageDirOf(root);
159
208
  await fs.mkdir(dir, { recursive: true });
160
209
  const fileName = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}-${stem}.${extension}`;
161
210
  const file = path.join(dir, fileName);
package/lib/note-store.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * ordered list of text segments; each segment may be archived after it was
6
6
  * sent, and only archived segments can be deleted by the client.
7
7
  *
8
- * Version 2 stores the segment list under `segments` and repeats the merged
9
- * body under `text` so the file stays human readable and older builds can
10
- * still read the content. Version 1 files (a single `text`) and hand-edited
11
- * plain-text files are migrated on read as one segment.
8
+ * Version 4 adds an optional card title and creation timestamp to every
9
+ * segment. Version 2 stores the segment list under `segments` and repeats the
10
+ * merged body under `text` so the file stays human readable and older builds
11
+ * can still read the content. Version 1 files (a single `text`) and
12
+ * hand-edited plain-text files are migrated on read as one segment.
12
13
  *
13
14
  * @module dsh-notepad/lib/note-store
14
15
  */
@@ -32,8 +33,14 @@ export const MAX_SEGMENTS = 200;
32
33
  /** Maximum characters in one segment. */
33
34
  export const MAX_SEGMENT_CHARS = 160_000;
34
35
 
36
+ /** Maximum characters kept for one segment title. */
37
+ export const MAX_SEGMENT_TITLE_CHARS = 200;
38
+
39
+ /** Maximum characters kept for one creation timestamp. */
40
+ export const MAX_SEGMENT_TIME_CHARS = 64;
41
+
35
42
  /** Storage envelope version. */
36
- export const NOTE_VERSION = 3;
43
+ export const NOTE_VERSION = 4;
37
44
 
38
45
  /** Maximum image references accepted on one segment. */
39
46
  export const MAX_IMAGES_PER_SEGMENT = 20;
@@ -184,7 +191,7 @@ export function segmentBody(segment) {
184
191
  * @param {unknown} value
185
192
  * @param {number} index
186
193
  * @param {Set<string>} seen
187
- * @returns {{id: string, text: string, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
194
+ * @returns {{id: string, text: string, title: string, createdAt: string | null, images: Array<{path: string, name: string}>, archived: boolean, archivedAt: string | null} | null}
188
195
  */
189
196
  function readSegment(value, index, seen) {
190
197
  if (typeof value === 'string') value = { text: value };
@@ -199,11 +206,15 @@ function readSegment(value, index, seen) {
199
206
  suffix += 1;
200
207
  }
201
208
  seen.add(id);
209
+ const title = typeof value.title === 'string' ? value.title.slice(0, MAX_SEGMENT_TITLE_CHARS) : '';
210
+ const createdAt = typeof value.createdAt === 'string' && value.createdAt !== ''
211
+ ? value.createdAt.slice(0, MAX_SEGMENT_TIME_CHARS)
212
+ : null;
202
213
  const archived = value.archived === true;
203
214
  const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
204
215
  ? value.archivedAt
205
216
  : null;
206
- return { id, text, images, archived, archivedAt };
217
+ return { id, text, title, createdAt, images, archived, archivedAt };
207
218
  }
208
219
 
209
220
  /**
@@ -219,7 +230,7 @@ function readSegment(value, index, seen) {
219
230
  export function normalizeSegments(input) {
220
231
  if (typeof input === 'string') {
221
232
  if (input === '') return [];
222
- return [{ id: 'seg-1', text: input, images: [], archived: false, archivedAt: null }];
233
+ return [{ id: 'seg-1', text: input, title: '', createdAt: null, images: [], archived: false, archivedAt: null }];
223
234
  }
224
235
  if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
225
236
  if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
@@ -271,7 +282,61 @@ function normalizeWriteSegment(value, index, seen) {
271
282
  const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
272
283
  ? value.archivedAt
273
284
  : null;
274
- return { id, text: value.text, images, archived, archivedAt };
285
+ const title = value.title === undefined || value.title === null
286
+ ? ''
287
+ : typeof value.title === 'string'
288
+ ? value.title.slice(0, MAX_SEGMENT_TITLE_CHARS)
289
+ : invalidTitle(index);
290
+ const createdAt = value.createdAt === undefined || value.createdAt === null
291
+ ? null
292
+ : typeof value.createdAt === 'string'
293
+ ? value.createdAt.slice(0, MAX_SEGMENT_TIME_CHARS)
294
+ : invalidCreatedAt(index);
295
+ return { id, text: value.text, title, createdAt, images, archived, archivedAt };
296
+ }
297
+
298
+ /**
299
+ * @param {number} index
300
+ * @returns {never}
301
+ */
302
+ function invalidTitle(index) {
303
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} title is not a string.`);
304
+ }
305
+
306
+ /**
307
+ * @param {number} index
308
+ * @returns {never}
309
+ */
310
+ function invalidCreatedAt(index) {
311
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} createdAt is not a string.`);
312
+ }
313
+
314
+ /**
315
+ * Whether one segment still counts as unsent work: it is active and carries
316
+ * text or at least one image. Archived segments were already sent or filed
317
+ * away, so they never contribute to the pending badge.
318
+ * @param {{text?: string, images?: unknown[], archived?: boolean}} segment
319
+ * @returns {boolean}
320
+ */
321
+ export function isUnsentSegment(segment) {
322
+ if (segment === null || typeof segment !== 'object' || segment.archived === true) return false;
323
+ const text = typeof segment.text === 'string' ? segment.text : '';
324
+ const images = Array.isArray(segment.images) ? segment.images : [];
325
+ return text.trim() !== '' || images.length > 0;
326
+ }
327
+
328
+ /**
329
+ * Number of pending segments in one decoded note.
330
+ * @param {{segments?: Array<{text?: string, images?: unknown[], archived?: boolean}>}} note
331
+ * @returns {number}
332
+ */
333
+ export function unsentCount(note) {
334
+ const segments = Array.isArray(note?.segments) ? note.segments : [];
335
+ let count = 0;
336
+ for (const segment of segments) {
337
+ if (isUnsentSegment(segment)) count += 1;
338
+ }
339
+ return count;
275
340
  }
276
341
 
277
342
  /**
@@ -367,6 +432,8 @@ export async function writeNote(root, note, options = {}) {
367
432
  segments: segments.map((segment) => ({
368
433
  id: segment.id,
369
434
  text: segment.text,
435
+ ...(segment.title !== '' ? { title: segment.title } : {}),
436
+ ...(segment.createdAt !== null ? { createdAt: segment.createdAt } : {}),
370
437
  ...(segment.images.length > 0
371
438
  ? { images: segment.images.map((image) => ({ path: image.path, name: image.name })) }
372
439
  : {}),
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Notepad",
4
- "description": "A per-workspace floating notepad in the session view. Enter creates segment cards; paste or pick images as path-based attachment chips; insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Resizable and dark-mode aware."
4
+ "description": "A per-workspace floating notepad in the session view. New segment cards lead the list, each card has an editable title; click an index to select, drag to reorder, merge a selection, and improve one or all cards with AI. Paste or pick images as path-based chips; removing a chip cleans up plugin-owned files. Pending counts appear on the hidden launcher and the sidebar workspace row. Insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Resizable and dark-mode aware."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "记事本",
4
- "description": "在会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段,可粘贴或选择图片并以路径附件形式引入,支持单段/多段插入、替换和发送,发送后自动归档;归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
4
+ "description": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,标题可编辑,点击序号选中,可拖拽排序、合并多段并用 AI 优化单段或全部;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.3.0",
4
- "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: Enter creates segment cards, paste or pick images as inline attachment chips, batch insert / replace / send, sending archives segments, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段卡片,可粘贴或选择图片并以路径附件形式引入,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
3
+ "version": "0.4.0",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with editable titles, drag to reorder, merge selected segments, AI improve one or all, click an index to select, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且标题可编辑,可拖拽排序、合并选中分段、AI 优化单段或全部、点击序号选中,图片芯片删除时自动清理磁盘文件,隐藏按钮与侧边栏工作区显示未发送数量角标,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",