@restregular/dsh-notepad 0.3.1 → 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.
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.1';
41
+ const VERSION = '0.5.0';
40
42
 
41
43
  /** Request body cap for note writes. */
42
44
  const BODY_LIMIT = 1024 * 1024;
@@ -44,6 +46,30 @@ 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
+ /** Entries kept in the in-process optimizer cache. */
59
+ const OPTIMIZE_CACHE_LIMIT = 64;
60
+
61
+ /** Maximum key + value characters kept in the optimizer cache. */
62
+ const OPTIMIZE_CACHE_CHARS = 2_000_000;
63
+
64
+ /** Shared instruction for the optional writing assistant. */
65
+ const OPTIMIZE_SYSTEM = [
66
+ 'You are a writing assistant for a developer notepad.',
67
+ 'Rewrite the user\'s note so it is clearer, more concise, and better organized,',
68
+ 'while preserving its original language, meaning, technical facts, Markdown,',
69
+ 'code, file paths, and image links. Never invent facts or drop information.',
70
+ 'Return only the rewritten note text: no preamble, no explanation, and no code fence.',
71
+ ].join(' ');
72
+
47
73
  // ---------------------------------------------------------------------------
48
74
  // HTTP helpers
49
75
  // ---------------------------------------------------------------------------
@@ -174,6 +200,166 @@ async function isKnownRoot(ctx, root, sessionId) {
174
200
  return false;
175
201
  }
176
202
 
203
+ /**
204
+ * Count pending segments for every registered workspace. The sidebar badge
205
+ * only needs counts, so this route never returns note text or image paths.
206
+ * @param {import('@deepseek-ai/cordis').Context} ctx
207
+ * @returns {Promise<Array<{id: string, path: string, title: string, count: number}>>}
208
+ */
209
+ async function listUnsentCounts(ctx) {
210
+ const registry = ctx.get('workspaceRegistry');
211
+ const list = registry !== undefined && typeof registry.list === 'function' ? registry.list() : [];
212
+ if (!Array.isArray(list)) return [];
213
+ const out = [];
214
+ for (const workspace of list) {
215
+ const root = typeof workspace?.path === 'string' ? workspace.path : '';
216
+ if (root === '') continue;
217
+ let count = 0;
218
+ try {
219
+ count = unsentCount(await readNote(root));
220
+ } catch {
221
+ // A workspace directory may have disappeared; keep the other badges.
222
+ count = 0;
223
+ }
224
+ out.push({
225
+ id: String(workspace.id ?? workspace.workspaceId ?? ''),
226
+ path: root,
227
+ title: typeof workspace.title === 'string' ? workspace.title : '',
228
+ count,
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * Strip a surrounding Markdown code fence the model may add despite the
236
+ * instruction, then trim. Inner code fences survive.
237
+ * @param {string} value
238
+ * @returns {string}
239
+ */
240
+ function cleanOptimizedText(value) {
241
+ let text = String(value ?? '').replace(/^\uFEFF/u, '').trim();
242
+ const fenced = /^```[^\n]*\r?\n([\s\S]*?)\r?\n```$/u.exec(text);
243
+ if (fenced !== null) text = fenced[1].trim();
244
+ return text;
245
+ }
246
+
247
+ /**
248
+ * Results of earlier optimizer calls. Re-optimizing content that has not
249
+ * changed returns the previous answer instead of billing another model call.
250
+ * The cache is per host process and bounded by entry count and characters.
251
+ * @type {Map<string, string>}
252
+ */
253
+ const optimizeCache = new Map();
254
+ let optimizeCacheChars = 0;
255
+
256
+ /**
257
+ * @param {string} key
258
+ * @param {string} text
259
+ */
260
+ function cacheOptimizedText(key, text) {
261
+ const previous = optimizeCache.get(key);
262
+ if (previous !== undefined) {
263
+ optimizeCacheChars -= key.length + previous.length;
264
+ optimizeCache.delete(key);
265
+ }
266
+ optimizeCache.set(key, text);
267
+ optimizeCacheChars += key.length + text.length;
268
+ while (optimizeCache.size > OPTIMIZE_CACHE_LIMIT || optimizeCacheChars > OPTIMIZE_CACHE_CHARS) {
269
+ const oldest = optimizeCache.keys().next();
270
+ if (oldest.done === true) break;
271
+ const value = optimizeCache.get(oldest.value) ?? '';
272
+ optimizeCacheChars -= oldest.value.length + value.length;
273
+ optimizeCache.delete(oldest.value);
274
+ }
275
+ }
276
+
277
+ /** Workspace + title + text: everything the optimizer prompt depends on. */
278
+ function optimizeCacheKey(root, title, text) {
279
+ return `${root}\u0000${title}\u0000${text}`;
280
+ }
281
+
282
+ /**
283
+ * Ask the currently selected default model to improve one note segment.
284
+ * The LLM service is optional: a deployment without one reports a stable
285
+ * `unavailable` code instead of failing the whole notepad.
286
+ * @param {import('@deepseek-ai/cordis').Context} ctx
287
+ * @param {string} text
288
+ * @param {string} title
289
+ * @returns {Promise<string>}
290
+ */
291
+ async function optimizeText(ctx, text, title) {
292
+ const llm = ctx.get('llm');
293
+ if (llm === undefined || llm === null || typeof llm.prepareCall !== 'function'
294
+ || typeof llm.resolveCallConfig !== 'function') {
295
+ throw new NoteStoreError('unavailable', 'The AI model service is not available.');
296
+ }
297
+ const defaultModel = ctx.get('agentDefaultModel');
298
+ if (defaultModel === undefined || defaultModel === null || typeof defaultModel.currentSelection !== 'function') {
299
+ throw new NoteStoreError('unavailable', 'No default model is configured.');
300
+ }
301
+ let selection;
302
+ try {
303
+ selection = defaultModel.currentSelection();
304
+ } catch (error) {
305
+ throw new NoteStoreError('unavailable', `No default model is configured: ${error?.message ?? error}`);
306
+ }
307
+ if (selection === undefined || selection === null
308
+ || typeof selection.provider !== 'string' || selection.provider === ''
309
+ || typeof selection.model !== 'string' || selection.model === '') {
310
+ throw new NoteStoreError('unavailable', 'No default model is configured.');
311
+ }
312
+ const signal = AbortSignal.timeout(OPTIMIZE_TIMEOUT_MS);
313
+ const maxTokens = Math.min(8192, Math.max(1024, Math.ceil(text.length / 2)));
314
+ let base;
315
+ try {
316
+ base = await llm.resolveCallConfig({
317
+ provider: selection.provider,
318
+ model: selection.model,
319
+ maxTokens,
320
+ });
321
+ } catch (error) {
322
+ throw new NoteStoreError('llm-failed', `Could not prepare the model call: ${error?.message ?? error}`);
323
+ }
324
+ if (typeof base !== 'object' || base === null) {
325
+ throw new NoteStoreError('llm-failed', 'The model call configuration is empty.');
326
+ }
327
+ const heading = title.trim() === '' ? '' : `Card title: ${title.trim()}\n\n`;
328
+ const prompt = `${heading}Rewrite this note:\n\n${text}`;
329
+ let output = '';
330
+ try {
331
+ const prepared = await llm.prepareCall(base, signal);
332
+ for await (const chunk of prepared.stream({
333
+ ...base,
334
+ system: OPTIMIZE_SYSTEM,
335
+ messages: [{
336
+ role: 'user',
337
+ content: [{ type: 'text', text: prompt }],
338
+ }],
339
+ signal,
340
+ })) {
341
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') output += chunk.text;
342
+ else if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') {
343
+ const failure = chunk.reason.failure;
344
+ throw new NoteStoreError('llm-failed', `The model call failed: ${failure?.message ?? 'unknown error'}`);
345
+ } else if (chunk?.type === 'finish' && chunk.reason?.kind === 'aborted') {
346
+ throw new NoteStoreError('timeout', 'The model did not answer in time.');
347
+ }
348
+ }
349
+ } catch (error) {
350
+ if (error instanceof NoteStoreError) throw error;
351
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
352
+ throw new NoteStoreError('timeout', 'The model did not answer in time.');
353
+ }
354
+ throw new NoteStoreError('llm-failed', `The model call failed: ${error?.message ?? error}`);
355
+ }
356
+ const cleaned = cleanOptimizedText(output);
357
+ if (cleaned === '') {
358
+ throw new NoteStoreError('llm-failed', 'The model returned no text.');
359
+ }
360
+ return cleaned;
361
+ }
362
+
177
363
  // ---------------------------------------------------------------------------
178
364
  // Route
179
365
  // ---------------------------------------------------------------------------
@@ -190,9 +376,12 @@ export async function handleRequest(ctx, req, res) {
190
376
  } catch (error) {
191
377
  if (error instanceof NoteStoreError) {
192
378
  let status = 400;
193
- if (error.code === 'unknown-workspace' || error.code === 'bad-root') status = 403;
379
+ if (error.code === 'unknown-workspace' || error.code === 'bad-root' || error.code === 'not-owned') status = 403;
194
380
  else if (error.code === 'too-large') status = 413;
195
381
  else if (error.code === 'unsupported-type') status = 415;
382
+ else if (error.code === 'unavailable') status = 503;
383
+ else if (error.code === 'timeout') status = 504;
384
+ else if (error.code === 'llm-failed') status = 502;
196
385
  sendJson(res, { ok: false, code: error.code, error: error.message }, status);
197
386
  return;
198
387
  }
@@ -230,18 +419,63 @@ async function dispatchRequest(ctx, req, res) {
230
419
  return;
231
420
  }
232
421
  if (action === 'image') {
422
+ if (req.method === 'POST') {
423
+ const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
424
+ const root = await resolveRequestRoot(ctx, body);
425
+ const image = await saveClipboardImage(root, {
426
+ name: body.name,
427
+ mime: body.mime,
428
+ data: body.data,
429
+ });
430
+ sendJson(res, { ok: true, root, ...image });
431
+ return;
432
+ }
433
+ if (req.method === 'DELETE') {
434
+ const body = await readJsonBody(req);
435
+ const root = await resolveRequestRoot(ctx, body);
436
+ const result = await deleteStoredImage(root, body.path);
437
+ sendJson(res, { ok: true, root, ...result });
438
+ return;
439
+ }
440
+ sendJson(res, { ok: false, code: 'method', error: 'Use POST or DELETE.' }, 405);
441
+ return;
442
+ }
443
+ if (action === 'unsent') {
444
+ if (req.method !== 'GET') {
445
+ sendJson(res, { ok: false, code: 'method', error: 'Use GET.' }, 405);
446
+ return;
447
+ }
448
+ const workspaces = await listUnsentCounts(ctx);
449
+ sendJson(res, { ok: true, workspaces });
450
+ return;
451
+ }
452
+ if (action === 'optimize') {
233
453
  if (req.method !== 'POST') {
234
454
  sendJson(res, { ok: false, code: 'method', error: 'Use POST.' }, 405);
235
455
  return;
236
456
  }
237
- const body = await readJsonBody(req, IMAGE_BODY_LIMIT);
457
+ const body = await readJsonBody(req);
238
458
  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 });
459
+ const text = typeof body.text === 'string' ? body.text : '';
460
+ if (text.trim() === '') {
461
+ throw new NoteStoreError('bad-input', 'There is no text to optimize.');
462
+ }
463
+ if (text.length > MAX_OPTIMIZE_CHARS) {
464
+ throw new NoteStoreError(
465
+ 'too-large',
466
+ `Segment is too large to optimize (${text.length} characters; limit ${MAX_OPTIMIZE_CHARS}).`,
467
+ );
468
+ }
469
+ const title = typeof body.title === 'string' ? body.title : '';
470
+ const cacheKey = optimizeCacheKey(root, title, text);
471
+ const cached = optimizeCache.get(cacheKey);
472
+ if (typeof cached === 'string' && cached !== '') {
473
+ sendJson(res, { ok: true, root, text: cached, cached: true });
474
+ return;
475
+ }
476
+ const optimized = await optimizeText(ctx, text, title);
477
+ cacheOptimizedText(cacheKey, optimized);
478
+ sendJson(res, { ok: true, root, text: optimized, cached: false });
245
479
  return;
246
480
  }
247
481
  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; an empty title is just a placeholder until clicked. Click an index to select, drag to reorder, merge a selection, move between cards with the arrow keys, and improve only the selected cards with AI — the button turns into undo and identical content reuses the previous answer. 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. Auto refresh keeps the list scroll position, Ctrl+Shift+0 resets the panel geometry, the panel never covers the host title bar, and it is 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 优化只作用于选中的分段,优化后按钮变为撤销,相同内容直接复用上次结果;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除;自动刷新保持滚动位置,浮窗可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,且不会越过窗口标题栏。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.3.1",
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.5.0",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with titles that appear only after a click, drag to reorder, merge selected segments, arrow keys to move between cards, AI improve limited to the selected segments with a one-click undo and cached results, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, scroll position kept across automatic refreshes, resizable panel, Ctrl+Shift+0 geometry reset, a title-bar-safe position, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且空标题只在点击后显示输入框,可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化,优化后按钮变为撤销并复用相同内容的缓存结果;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除;自动刷新保持列表滚动位置;可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",