@restregular/dsh-notepad 0.1.0 → 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.
package/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  const ROUTE = '/dsh-notepad/api';
29
29
 
30
30
  /** Plugin version mirrored in package.json. */
31
- const VERSION = '0.1.0';
31
+ const VERSION = '0.2.0';
32
32
 
33
33
  /** Request body cap. */
34
34
  const BODY_LIMIT = 1024 * 1024;
@@ -218,7 +218,9 @@ async function dispatchRequest(ctx, req, res) {
218
218
  if (req.method === 'PUT') {
219
219
  const body = await readJsonBody(req);
220
220
  const root = await resolveRequestRoot(ctx, body);
221
- const note = await writeNote(root, body.text, { maxBytes: DEFAULT_MAX_BYTES });
221
+ const note = await writeNote(root, Array.isArray(body.segments) ? body.segments : body.text, {
222
+ maxBytes: DEFAULT_MAX_BYTES,
223
+ });
222
224
  sendJson(res, { ok: true, root, ...note });
223
225
  return;
224
226
  }
package/lib/note-store.js CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * dsh-notepad — workspace note storage.
3
3
  *
4
- * One plain-text note per workspace, kept at `<workspace>/.dsh/notepad.json`.
5
- * The JSON envelope lets the file carry its own `updatedAt` while still
6
- * degrading gracefully: if the file is not JSON (a user or another tool
7
- * edited it by hand), the raw bytes are returned as the note text instead of
8
- * losing the content.
4
+ * One note per workspace, kept at `<workspace>/.dsh/notepad.json`. A note is an
5
+ * ordered list of text segments; each segment may be archived after it was
6
+ * sent, and only archived segments can be deleted by the client.
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.
9
12
  *
10
13
  * @module dsh-notepad/lib/note-store
11
14
  */
@@ -20,11 +23,17 @@ export const NOTE_DIR = '.dsh';
20
23
  /** File name inside {@link NOTE_DIR}. */
21
24
  export const NOTE_FILE = 'notepad.json';
22
25
 
23
- /** Default maximum note size (UTF-8 bytes). */
26
+ /** Default maximum note file size (UTF-8 bytes). */
24
27
  export const DEFAULT_MAX_BYTES = 512 * 1024;
25
28
 
29
+ /** Maximum number of segments accepted from the client. */
30
+ export const MAX_SEGMENTS = 200;
31
+
32
+ /** Maximum characters in one segment. */
33
+ export const MAX_SEGMENT_CHARS = 160_000;
34
+
26
35
  /** Storage envelope version. */
27
- export const NOTE_VERSION = 1;
36
+ export const NOTE_VERSION = 2;
28
37
 
29
38
  /** A note-store failure with a stable machine-readable code. */
30
39
  export class NoteStoreError extends Error {
@@ -47,25 +56,139 @@ export function notePath(root) {
47
56
  return path.join(root, NOTE_DIR, NOTE_FILE);
48
57
  }
49
58
 
59
+ /**
60
+ * Normalize one segment read from disk. Reading is forgiving: a malformed
61
+ * entry is skipped instead of making the whole note unreadable.
62
+ * @param {unknown} value
63
+ * @param {number} index
64
+ * @param {Set<string>} seen
65
+ * @returns {{id: string, text: string, archived: boolean, archivedAt: string | null} | null}
66
+ */
67
+ function readSegment(value, index, seen) {
68
+ if (typeof value === 'string') value = { text: value };
69
+ if (value === null || typeof value !== 'object') return null;
70
+ if (typeof value.text !== 'string') return null;
71
+ const text = value.text;
72
+ let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
73
+ let suffix = 1;
74
+ while (seen.has(id)) {
75
+ id = `${id}-${suffix}`;
76
+ suffix += 1;
77
+ }
78
+ seen.add(id);
79
+ const archived = value.archived === true;
80
+ const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
81
+ ? value.archivedAt
82
+ : null;
83
+ return { id, text, archived, archivedAt };
84
+ }
85
+
86
+ /**
87
+ * Normalize a segment list for a write. Unlike {@link readSegment}, malformed
88
+ * entries are an input error here: the route must not silently drop content.
89
+ *
90
+ * Accepts the modern `segments` array, a single legacy text string, or
91
+ * `{ text }`.
92
+ *
93
+ * @param {unknown} input
94
+ * @returns {Array<{id: string, text: string, archived: boolean, archivedAt: string | null}>}
95
+ */
96
+ export function normalizeSegments(input) {
97
+ if (typeof input === 'string') {
98
+ if (input === '') return [];
99
+ return [{ id: 'seg-1', text: input, archived: false, archivedAt: null }];
100
+ }
101
+ if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
102
+ if (Array.isArray(input.segments)) return normalizeSegments(input.segments);
103
+ if (typeof input.text === 'string') return normalizeSegments(input.text);
104
+ throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
105
+ }
106
+ if (!Array.isArray(input)) {
107
+ throw new NoteStoreError('bad-input', 'Note must be a string or a segments array.');
108
+ }
109
+ if (input.length > MAX_SEGMENTS) {
110
+ throw new NoteStoreError('too-many', `Too many segments (${input.length}; limit ${MAX_SEGMENTS}).`);
111
+ }
112
+ const seen = new Set();
113
+ return input.map((value, index) => {
114
+ const segment = normalizeWriteSegment(value, index, seen);
115
+ return segment;
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Strict per-segment normalization for writes.
121
+ * @param {unknown} value
122
+ * @param {number} index
123
+ * @param {Set<string>} seen
124
+ */
125
+ function normalizeWriteSegment(value, index, seen) {
126
+ if (typeof value === 'string') value = { text: value };
127
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
128
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} is not an object.`);
129
+ }
130
+ if (typeof value.text !== 'string') {
131
+ throw new NoteStoreError('bad-input', `Segment ${index + 1} has no text field.`);
132
+ }
133
+ if (value.text.length > MAX_SEGMENT_CHARS) {
134
+ throw new NoteStoreError(
135
+ 'too-large',
136
+ `Segment ${index + 1} is too large (${value.text.length} characters; limit ${MAX_SEGMENT_CHARS}).`,
137
+ );
138
+ }
139
+ let id = typeof value.id === 'string' && value.id !== '' ? value.id.slice(0, 128) : `seg-${index + 1}`;
140
+ let suffix = 1;
141
+ while (seen.has(id)) {
142
+ id = `${id}-${suffix}`;
143
+ suffix += 1;
144
+ }
145
+ seen.add(id);
146
+ const archived = value.archived === true;
147
+ const archivedAt = archived && typeof value.archivedAt === 'string' && value.archivedAt !== ''
148
+ ? value.archivedAt
149
+ : null;
150
+ return { id, text: value.text, archived, archivedAt };
151
+ }
152
+
153
+ /**
154
+ * Merge segments into the plain body stored next to the list.
155
+ * @param {Array<{text: string}>} segments
156
+ */
157
+ export function mergeSegments(segments) {
158
+ return segments.map((segment) => segment.text).join('\n\n');
159
+ }
160
+
50
161
  /**
51
162
  * Decode a stored note file.
52
163
  * @param {string} raw file contents
53
164
  * @param {number | undefined} mtimeMs
54
165
  */
55
166
  export function parseNote(raw, mtimeMs) {
56
- let text = raw;
167
+ let segmentsInput;
57
168
  let updatedAt;
58
169
  try {
59
170
  const parsed = JSON.parse(raw);
60
- if (parsed !== null && typeof parsed === 'object' && typeof parsed.text === 'string') {
61
- text = parsed.text;
171
+ if (parsed !== null && typeof parsed === 'object') {
172
+ if (Array.isArray(parsed.segments)) segmentsInput = parsed.segments;
173
+ else if (typeof parsed.text === 'string') segmentsInput = parsed.text;
62
174
  if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
63
175
  }
64
176
  } catch {
65
177
  /* Not our envelope: keep the raw text. */
66
178
  }
179
+ if (segmentsInput === undefined) segmentsInput = raw;
180
+ const seen = new Set();
181
+ const segments = [];
182
+ const values = typeof segmentsInput === 'string'
183
+ ? (segmentsInput === '' ? [] : [segmentsInput])
184
+ : Array.isArray(segmentsInput) ? segmentsInput : [];
185
+ for (const value of values) {
186
+ const segment = readSegment(value, segments.length, seen);
187
+ if (segment !== null) segments.push(segment);
188
+ }
67
189
  return {
68
- text,
190
+ segments,
191
+ text: mergeSegments(segments),
69
192
  updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
70
193
  mtimeMs: typeof mtimeMs === 'number' ? mtimeMs : null,
71
194
  exists: true,
@@ -76,7 +199,7 @@ export function parseNote(raw, mtimeMs) {
76
199
  /**
77
200
  * Read one workspace note. A missing file is an empty note, not an error.
78
201
  * @param {string} root workspace root
79
- * @returns {Promise<{text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
202
+ * @returns {Promise<{segments: Array<{id: string, text: string, archived: boolean, archivedAt: string | null}>, text: string, updatedAt: string | null, mtimeMs: number | null, exists: boolean, size: number}>}
80
203
  */
81
204
  export async function readNote(root) {
82
205
  const file = notePath(root);
@@ -85,7 +208,7 @@ export async function readNote(root) {
85
208
  raw = await fs.readFile(file, 'utf8');
86
209
  } catch (error) {
87
210
  if (error?.code === 'ENOENT') {
88
- return { text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
211
+ return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
89
212
  }
90
213
  throw error;
91
214
  }
@@ -100,25 +223,34 @@ export async function readNote(root) {
100
223
  }
101
224
 
102
225
  /**
103
- * Replace one workspace note atomically.
226
+ * Replace one workspace note atomically. Accepts either a segment array or a
227
+ * legacy plain-text string.
104
228
  * @param {string} root workspace root
105
- * @param {string} text note body
229
+ * @param {unknown} note segments array or legacy text
106
230
  * @param {{ maxBytes?: number }} [options]
107
231
  */
108
- export async function writeNote(root, text, options = {}) {
109
- if (typeof text !== 'string') {
110
- throw new NoteStoreError('bad-input', 'Note text must be a string.');
111
- }
232
+ export async function writeNote(root, note, options = {}) {
233
+ const segments = normalizeSegments(note);
112
234
  const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
113
- const size = Buffer.byteLength(text, 'utf8');
235
+ const dir = path.join(root, NOTE_DIR);
236
+ const file = path.join(dir, NOTE_FILE);
237
+ const updatedAt = new Date().toISOString();
238
+ const payload = `${JSON.stringify({
239
+ version: NOTE_VERSION,
240
+ segments: segments.map((segment) => ({
241
+ id: segment.id,
242
+ text: segment.text,
243
+ ...(segment.archived ? { archived: true } : {}),
244
+ ...(segment.archivedAt ? { archivedAt: segment.archivedAt } : {}),
245
+ })),
246
+ text: mergeSegments(segments),
247
+ updatedAt,
248
+ }, null, 2)}\n`;
249
+ const size = Buffer.byteLength(payload, 'utf8');
114
250
  if (size > maxBytes) {
115
251
  throw new NoteStoreError('too-large', `Note is too large (${size} bytes; limit ${maxBytes} bytes).`);
116
252
  }
117
- const dir = path.join(root, NOTE_DIR);
118
- const file = path.join(dir, NOTE_FILE);
119
253
  await fs.mkdir(dir, { recursive: true });
120
- const updatedAt = new Date().toISOString();
121
- const payload = `${JSON.stringify({ version: NOTE_VERSION, text, updatedAt }, null, 2)}\n`;
122
254
  const tmp = path.join(dir, `.${NOTE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
123
255
  await fs.writeFile(tmp, payload, 'utf8');
124
256
  try {
@@ -129,7 +261,8 @@ export async function writeNote(root, text, options = {}) {
129
261
  }
130
262
  const stat = await fs.stat(file);
131
263
  return {
132
- text,
264
+ segments,
265
+ text: mergeSegments(segments),
133
266
  updatedAt,
134
267
  mtimeMs: stat.mtimeMs,
135
268
  exists: true,
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: jot down problems and next steps, insert the note into the composer with one click, drag, minimize, and hide."
4
+ "description": "A per-workspace floating notepad in the session view. Enter creates segment cards; 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": "在会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段,支持单段/多段插入、替换和发送,发送后自动归档;归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.1.0",
4
- "description": "Notepad — a per-workspace floating note window in the DeepSeek Harness session view: jot down problems and next steps, insert the note into the composer with one click, drag, minimize, and hide. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的文本浮窗:记录问题与下一步,一键插入输入框,支持拖拽、最小化和快捷键隐藏。",
3
+ "version": "0.2.0",
4
+ "description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: Enter creates segment cards, batch insert / replace / send, sending archives segments, archive management, resizable panel and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段卡片,支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除,可调整尺寸并适配深色模式。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",