@restregular/dsh-notepad 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ # dsh-notepad bundle patch: inserts the Host plugin (the loopback note store
2
+ # route under /dsh-notepad/api) and the Client plugin (the floating notepad
3
+ # registered into the shell.overlay slot).
4
+ #
5
+ # `name` must be byte-for-byte the package.json name: the client module graph
6
+ # locates the package by the Loader row name.
7
+ - insert:
8
+ - id: dsh-notepad
9
+ name: '@restregular/dsh-notepad'
package/icon.svg ADDED
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none">
2
+ <defs>
3
+ <linearGradient id="bg" x1="32" y1="24" x2="224" y2="232" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#4D6BFE"/>
5
+ <stop offset="1" stop-color="#2E3FCB"/>
6
+ </linearGradient>
7
+ <linearGradient id="paper" x1="72" y1="64" x2="176" y2="208" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="#FFFFFF"/>
9
+ <stop offset="1" stop-color="#E8ECFF"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
13
+ <path d="M78 66h100a18 18 0 0 1 18 18v102a18 18 0 0 1-18 18H78a18 18 0 0 1-18-18V84a18 18 0 0 1 18-18Z" fill="url(#paper)"/>
14
+ <path d="M88 96h56" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
15
+ <path d="M88 128h72" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
16
+ <path d="M88 160h40" stroke="#9AA9FF" stroke-width="12" stroke-linecap="round"/>
17
+ <path d="m178.5 96.5 17 17a10 10 0 0 1 0 14.1l-44.3 44.3-22.9 6.3a5 5 0 0 1-6.2-6.2l6.3-22.9 44.3-44.3a10 10 0 0 1 14.1 0Z" fill="#FFB020" stroke="#2E3FCB" stroke-width="6" stroke-linejoin="round"/>
18
+ <path d="m166.5 108.5 17 17" stroke="#2E3FCB" stroke-width="6" stroke-linecap="round"/>
19
+ </svg>
package/index.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * dsh-notepad — Host half.
3
+ *
4
+ * Stores one plain-text note per workspace in `<workspace>/.dsh/notepad.json`
5
+ * and exposes it to the same-origin browser overlay through the loopback route
6
+ * `/dsh-notepad/api/note`.
7
+ *
8
+ * Security:
9
+ * - only loopback requests with a same-origin (or absent) Origin are served;
10
+ * - the resolved root must be a registered workspace or the exact cwd of the
11
+ * requesting session;
12
+ * - the file path itself is fixed (`.dsh/notepad.json`), so the route cannot
13
+ * be used to read or write arbitrary workspace files.
14
+ *
15
+ * @module dsh-notepad
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import {
20
+ DEFAULT_MAX_BYTES,
21
+ NOTE_VERSION,
22
+ NoteStoreError,
23
+ readNote,
24
+ writeNote,
25
+ } from './lib/note-store.js';
26
+
27
+ /** Loopback route prefix. */
28
+ const ROUTE = '/dsh-notepad/api';
29
+
30
+ /** Plugin version mirrored in package.json. */
31
+ const VERSION = '0.2.0';
32
+
33
+ /** Request body cap. */
34
+ const BODY_LIMIT = 1024 * 1024;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // HTTP helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** @param {import('node:http').ServerResponse} res */
41
+ function sendJson(res, payload, status = 200) {
42
+ const body = JSON.stringify(payload);
43
+ res.writeHead(status, {
44
+ 'Content-Type': 'application/json; charset=utf-8',
45
+ 'Cache-Control': 'no-store',
46
+ });
47
+ res.end(body);
48
+ }
49
+
50
+ /**
51
+ * Read and parse a JSON object body.
52
+ * @param {import('node:http').IncomingMessage} req
53
+ * @param {number} limit
54
+ */
55
+ function readJsonBody(req, limit = BODY_LIMIT) {
56
+ return new Promise((resolve, reject) => {
57
+ const chunks = [];
58
+ let total = 0;
59
+ req.on('data', (chunk) => {
60
+ total += chunk.length;
61
+ if (total > limit) {
62
+ reject(new NoteStoreError('too-large', 'Request body is too large.'));
63
+ req.destroy();
64
+ return;
65
+ }
66
+ chunks.push(chunk);
67
+ });
68
+ req.on('end', () => {
69
+ try {
70
+ const text = Buffer.concat(chunks).toString('utf8');
71
+ const value = text === '' ? {} : JSON.parse(text);
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
73
+ throw new Error('not an object');
74
+ }
75
+ resolve(value);
76
+ } catch {
77
+ reject(new NoteStoreError('bad-input', 'Request body is not a JSON object.'));
78
+ }
79
+ });
80
+ req.on('error', reject);
81
+ });
82
+ }
83
+
84
+ /** Only the same machine may drive the route. */
85
+ function isLoopback(req) {
86
+ const address = req.socket?.remoteAddress ?? '';
87
+ return (
88
+ address === '127.0.0.1' ||
89
+ address === '::1' ||
90
+ address.startsWith('::ffff:127.') ||
91
+ address === ''
92
+ );
93
+ }
94
+
95
+ /** Reject cross-origin form posts; the page is served from the same origin. */
96
+ function sameOrigin(req) {
97
+ const origin = req.headers.origin;
98
+ if (typeof origin !== 'string' || origin === '') return true;
99
+ try {
100
+ return new URL(origin).host === req.headers.host;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Workspace validation
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Case-insensitive path equality (Windows) with lexical normalization. */
111
+ function samePath(a, b) {
112
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
113
+ const norm = (value) => {
114
+ let out = value.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '');
115
+ if (process.platform === 'win32') out = out.toLowerCase();
116
+ return out;
117
+ };
118
+ return norm(a) === norm(b);
119
+ }
120
+
121
+ /**
122
+ * Resolve the workspace root a request is allowed to touch.
123
+ * @param {import('@deepseek-ai/cordis').Context} ctx
124
+ * @param {Record<string, unknown>} args
125
+ * @returns {Promise<string>}
126
+ */
127
+ async function resolveRequestRoot(ctx, args) {
128
+ const requested = typeof args.root === 'string' ? args.root : '';
129
+ if (requested === '') {
130
+ throw new NoteStoreError('bad-root', 'root is required.');
131
+ }
132
+ const root = path.resolve(requested);
133
+ if (await isKnownRoot(ctx, root, typeof args.sessionId === 'string' ? args.sessionId : '')) {
134
+ return root;
135
+ }
136
+ throw new NoteStoreError('unknown-workspace', 'This directory is not a registered workspace.');
137
+ }
138
+
139
+ /**
140
+ * A root is allowed when it is a registered workspace or the exact working
141
+ * directory of the requesting session.
142
+ * @param {import('@deepseek-ai/cordis').Context} ctx
143
+ * @param {string} root
144
+ * @param {string} sessionId
145
+ */
146
+ async function isKnownRoot(ctx, root, sessionId) {
147
+ const registry = ctx.get('workspaceRegistry');
148
+ if (registry !== undefined && typeof registry.list === 'function') {
149
+ const known = registry.list();
150
+ if (Array.isArray(known) && known.some((workspace) => samePath(workspace?.path, root))) return true;
151
+ }
152
+ if (sessionId !== '') {
153
+ const agent = ctx.get('agents')?.get?.(sessionId);
154
+ const cwd = agent?.session?.header?.cwd;
155
+ if (samePath(cwd, root)) return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Route
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Handle one API request. Exported for route tests.
166
+ * @param {import('@deepseek-ai/cordis').Context} ctx
167
+ * @param {import('node:http').IncomingMessage} req
168
+ * @param {import('node:http').ServerResponse} res
169
+ */
170
+ export async function handleRequest(ctx, req, res) {
171
+ try {
172
+ await dispatchRequest(ctx, req, res);
173
+ } catch (error) {
174
+ if (error instanceof NoteStoreError) {
175
+ const status = error.code === 'unknown-workspace' || error.code === 'bad-root' ? 403 : 400;
176
+ sendJson(res, { ok: false, code: error.code, error: error.message }, status);
177
+ return;
178
+ }
179
+ ctx.logger?.warn?.(`dsh-notepad: ${error?.stack ?? error}`);
180
+ sendJson(res, { ok: false, code: 'internal', error: String(error?.message ?? error) }, 500);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Dispatch one already-authenticated API request.
186
+ * @param {import('@deepseek-ai/cordis').Context} ctx
187
+ * @param {import('node:http').IncomingMessage} req
188
+ * @param {import('node:http').ServerResponse} res
189
+ */
190
+ async function dispatchRequest(ctx, req, res) {
191
+ if (!isLoopback(req) || !sameOrigin(req)) {
192
+ sendJson(res, { ok: false, code: 'forbidden', error: 'Forbidden.' }, 403);
193
+ return;
194
+ }
195
+ const url = new URL(req.url ?? '/', 'http://localhost');
196
+ if (!url.pathname.startsWith(ROUTE)) {
197
+ sendJson(res, { ok: false, code: 'not-found', error: 'Not found.' }, 404);
198
+ return;
199
+ }
200
+ const action = url.pathname.slice(ROUTE.length).replace(/^\/+|\/+$/gu, '');
201
+ if (action === 'ping') {
202
+ sendJson(res, { ok: true, plugin: 'dsh-notepad', version: VERSION, noteVersion: NOTE_VERSION });
203
+ return;
204
+ }
205
+ if (action !== 'note') {
206
+ sendJson(res, { ok: false, code: 'not-found', error: `Unknown endpoint "${action}".` }, 404);
207
+ return;
208
+ }
209
+ if (req.method === 'GET') {
210
+ const root = await resolveRequestRoot(ctx, {
211
+ root: url.searchParams.get('root') ?? '',
212
+ sessionId: url.searchParams.get('sessionId') ?? '',
213
+ });
214
+ const note = await readNote(root);
215
+ sendJson(res, { ok: true, root, ...note });
216
+ return;
217
+ }
218
+ if (req.method === 'PUT') {
219
+ const body = await readJsonBody(req);
220
+ const root = await resolveRequestRoot(ctx, body);
221
+ const note = await writeNote(root, Array.isArray(body.segments) ? body.segments : body.text, {
222
+ maxBytes: DEFAULT_MAX_BYTES,
223
+ });
224
+ sendJson(res, { ok: true, root, ...note });
225
+ return;
226
+ }
227
+ sendJson(res, { ok: false, code: 'method', error: 'Use GET or PUT.' }, 405);
228
+ }
229
+
230
+ /**
231
+ * Mount the loopback route.
232
+ * @param {import('@deepseek-ai/cordis').Context} ctx
233
+ */
234
+ function mountRoutes(ctx) {
235
+ const webServer = ctx.webServer ?? ctx.get('webServer');
236
+ if (webServer === undefined) return;
237
+ ctx.effect(
238
+ () =>
239
+ webServer.register({
240
+ kind: 'prefix',
241
+ path: ROUTE,
242
+ async handler(req, res) {
243
+ try {
244
+ await handleRequest(ctx, req, res);
245
+ } catch (error) {
246
+ ctx.logger?.warn?.(`dsh-notepad: ${error?.stack ?? error}`);
247
+ }
248
+ },
249
+ }),
250
+ 'dsh-notepad: loopback note route',
251
+ );
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Plugin body
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /** Loader identity of the Host half. */
259
+ export const name = 'dsh-notepad';
260
+
261
+ /**
262
+ * Mount the note route. Without `webServer` the overlay degrades to an
263
+ * in-memory note for the lifetime of the page.
264
+ * @param {import('@deepseek-ai/cordis').Context} ctx
265
+ */
266
+ export function apply(ctx) {
267
+ ctx.inject(['webServer'], (scoped) => mountRoutes(scoped));
268
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * dsh-notepad — workspace note storage.
3
+ *
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.
12
+ *
13
+ * @module dsh-notepad/lib/note-store
14
+ */
15
+
16
+ import { randomBytes } from 'node:crypto';
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+
20
+ /** Directory under the workspace root. */
21
+ export const NOTE_DIR = '.dsh';
22
+
23
+ /** File name inside {@link NOTE_DIR}. */
24
+ export const NOTE_FILE = 'notepad.json';
25
+
26
+ /** Default maximum note file size (UTF-8 bytes). */
27
+ export const DEFAULT_MAX_BYTES = 512 * 1024;
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
+
35
+ /** Storage envelope version. */
36
+ export const NOTE_VERSION = 2;
37
+
38
+ /** A note-store failure with a stable machine-readable code. */
39
+ export class NoteStoreError extends Error {
40
+ /**
41
+ * @param {string} code
42
+ * @param {string} message
43
+ */
44
+ constructor(code, message) {
45
+ super(message);
46
+ this.name = 'NoteStoreError';
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Absolute path of the note file.
53
+ * @param {string} root workspace root
54
+ */
55
+ export function notePath(root) {
56
+ return path.join(root, NOTE_DIR, NOTE_FILE);
57
+ }
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
+
161
+ /**
162
+ * Decode a stored note file.
163
+ * @param {string} raw file contents
164
+ * @param {number | undefined} mtimeMs
165
+ */
166
+ export function parseNote(raw, mtimeMs) {
167
+ let segmentsInput;
168
+ let updatedAt;
169
+ try {
170
+ const parsed = JSON.parse(raw);
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;
174
+ if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
175
+ }
176
+ } catch {
177
+ /* Not our envelope: keep the raw text. */
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
+ }
189
+ return {
190
+ segments,
191
+ text: mergeSegments(segments),
192
+ updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
193
+ mtimeMs: typeof mtimeMs === 'number' ? mtimeMs : null,
194
+ exists: true,
195
+ size: Buffer.byteLength(raw, 'utf8'),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Read one workspace note. A missing file is an empty note, not an error.
201
+ * @param {string} root workspace root
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}>}
203
+ */
204
+ export async function readNote(root) {
205
+ const file = notePath(root);
206
+ let raw;
207
+ try {
208
+ raw = await fs.readFile(file, 'utf8');
209
+ } catch (error) {
210
+ if (error?.code === 'ENOENT') {
211
+ return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
212
+ }
213
+ throw error;
214
+ }
215
+ let mtimeMs;
216
+ try {
217
+ const stat = await fs.stat(file);
218
+ mtimeMs = stat.mtimeMs;
219
+ } catch {
220
+ mtimeMs = undefined;
221
+ }
222
+ return parseNote(raw, mtimeMs);
223
+ }
224
+
225
+ /**
226
+ * Replace one workspace note atomically. Accepts either a segment array or a
227
+ * legacy plain-text string.
228
+ * @param {string} root workspace root
229
+ * @param {unknown} note segments array or legacy text
230
+ * @param {{ maxBytes?: number }} [options]
231
+ */
232
+ export async function writeNote(root, note, options = {}) {
233
+ const segments = normalizeSegments(note);
234
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
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');
250
+ if (size > maxBytes) {
251
+ throw new NoteStoreError('too-large', `Note is too large (${size} bytes; limit ${maxBytes} bytes).`);
252
+ }
253
+ await fs.mkdir(dir, { recursive: true });
254
+ const tmp = path.join(dir, `.${NOTE_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
255
+ await fs.writeFile(tmp, payload, 'utf8');
256
+ try {
257
+ await fs.rename(tmp, file);
258
+ } catch (error) {
259
+ await fs.rm(tmp, { force: true }).catch(() => {});
260
+ throw error;
261
+ }
262
+ const stat = await fs.stat(file);
263
+ return {
264
+ segments,
265
+ text: mergeSegments(segments),
266
+ updatedAt,
267
+ mtimeMs: stat.mtimeMs,
268
+ exists: true,
269
+ size: stat.size,
270
+ };
271
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notepad",
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
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "记事本",
4
+ "description": "在会话界面显示一个按工作区共享的分段便签浮窗:回车新建分段,支持单段/多段插入、替换和发送,发送后自动归档;归档可显示、隐藏、恢复与删除,浮窗可调整尺寸并适配深色模式。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@restregular/dsh-notepad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "client.js",
19
+ "lib/",
20
+ "locale/*.json",
21
+ "cordis.patch.yml",
22
+ "icon.svg",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "platform": "web",
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-locale",
34
+ "@deepseek-ai/dsh-client-ui-layout",
35
+ "@deepseek-ai/dsh-api-session-controller",
36
+ "@deepseek-ai/dsh-client-ui-session",
37
+ "@deepseek-ai/dsh-client-ui-conversation"
38
+ ]
39
+ }
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/RestRegular/dsh-notepad.git"
44
+ },
45
+ "homepage": "https://github.com/RestRegular/dsh-notepad#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/RestRegular/dsh-notepad/issues"
48
+ },
49
+ "keywords": [
50
+ "dsh-plugin",
51
+ "deepseek-harness",
52
+ "deepseek",
53
+ "notepad",
54
+ "notes",
55
+ "scratchpad",
56
+ "workspace",
57
+ "floating"
58
+ ],
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "engines": {
64
+ "node": ">=22.13.0"
65
+ },
66
+ "license": "MIT",
67
+ "author": "RestRegular",
68
+ "scripts": {
69
+ "test": "node test/note-store.test.mjs && node test/route.test.mjs && node test/client-smoke.mjs && node test/package.test.mjs"
70
+ }
71
+ }