@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.
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/client.js +2072 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +19 -0
- package/index.js +268 -0
- package/lib/note-store.js +271 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -4
package/cordis.patch.yml
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-notepad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|