dsh-plugin-notepad 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `notepad` — a deliberately small DeepSeek Harness plugin that records short
3
+ * notes.
4
+ *
5
+ * It registers three model-facing tools on `ctx.tools` and, when the deployment
6
+ * has a command registry, one human `/note` command:
7
+ *
8
+ * - `notepad_add` — append one note.
9
+ * - `notepad_list` — read notes back, newest first.
10
+ * - `notepad_clear` — delete one level's notes (requires `confirm: true`).
11
+ *
12
+ * Notes live in one of two levels, selected by the `level` / `defaultScope`
13
+ * config and overridable per call:
14
+ *
15
+ * - `global` → `<dshHome>/notepad/notes.jsonl`, shared by every project.
16
+ * - `project` → `<projectRoot>/.dsh/notepad/notes.jsonl`, one file per project.
17
+ *
18
+ * @module dsh-plugin-notepad
19
+ */
20
+ import z from '@deepseek-ai/schemastery'
21
+ import { defineTool } from '@deepseek-ai/dsh-tools'
22
+ import { API_PREFIX, ApiError, CLIENT_HEADER, MAX_BODY_BYTES, createNotepadApi } from './api.js'
23
+ import {
24
+ DEFAULT_FILE_NAME,
25
+ DEFAULT_LIST_LIMIT,
26
+ DEFAULT_MAX_CONTENT_LENGTH,
27
+ DEFAULT_MAX_NOTES,
28
+ DEFAULT_PROJECT_MARKERS,
29
+ LEVELS,
30
+ NotepadStore,
31
+ SCOPES,
32
+ normalizeLevel,
33
+ } from './store.js'
34
+
35
+ /** Loader entry name. */
36
+ export const name = 'notepad'
37
+ /** Required services: the tool registry (the command registry is optional). */
38
+ export const inject = ['tools']
39
+
40
+ const LEVEL_SCHEMA = z.union([z.const('global'), z.const('project'), z.const('both')])
41
+ const SCOPE_SCHEMA = z.union([z.const('global'), z.const('project')])
42
+
43
+ /**
44
+ * Deployment configuration.
45
+ *
46
+ * `level` chooses which levels exist here and `defaultScope` chooses where an
47
+ * omitted scope lands, so one config shape covers both supported deployments:
48
+ * a global notepad (`level: global`), a project notepad (`level: project`), or
49
+ * both at once (`level: both`).
50
+ */
51
+ export const Config = z.object({
52
+ level: LEVEL_SCHEMA.default('both').description('Note levels this deployment exposes: `global` ($DSH_HOME), `project` (per project root), or `both`.'),
53
+ defaultScope: SCOPE_SCHEMA.default('project').description('Level used when a call omits `scope`.'),
54
+ dshHome: z.string().default('').description('Harness home for the global level; empty resolves $DSH_HOME, then ~/.dsh.'),
55
+ globalDir: z.string().default('').description('Global note directory; empty uses `<dshHome>/notepad`.'),
56
+ projectDir: z.string().default('').description('Project note directory; empty uses `<projectRoot>/.dsh/notepad`.'),
57
+ fileName: z.string().default(DEFAULT_FILE_NAME).description('Note file name inside a level directory.'),
58
+ projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_MARKERS]).description('Ancestor markers that identify a project root, nearest first.'),
59
+ maxNotes: z.natural().default(DEFAULT_MAX_NOTES).description('Notes kept per file; older notes are pruned. 0 disables pruning.'),
60
+ maxContentLength: z.natural().default(DEFAULT_MAX_CONTENT_LENGTH).description('Maximum characters in one note.'),
61
+ defaultLimit: z.natural().default(DEFAULT_LIST_LIMIT).description('Notes returned by a listing that omits `limit`.'),
62
+ toolPrefix: z.string().default('notepad').description('Prefix of the registered tool names.'),
63
+ enableCommand: z.boolean().default(true).description('Whether to register the human `/note` command when a command registry is present.'),
64
+ })
65
+
66
+ /**
67
+ * Resolve raw config into the effective options, applying the same defaults the
68
+ * Config schema would. `apply` stays correct when it is called with a plain
69
+ * object (tests, `cordis:` builtins) instead of loader-validated config.
70
+ * @param {Record<string, unknown>} [config] - raw plugin config.
71
+ * @returns {{ level: 'global' | 'project' | 'both', defaultScope: 'global' | 'project', dshHome: string, globalDir: string, projectDir: string, fileName: string, projectRootMarkers: string[], maxNotes: number, maxContentLength: number, defaultLimit: number, toolPrefix: string, enableCommand: boolean }} effective options.
72
+ */
73
+ export function resolveOptions(config = {}) {
74
+ const raw = typeof config === 'object' && config !== null ? config : {}
75
+ const text = (value, fallback) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback)
76
+ const count = (value, fallback) => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback)
77
+ // An explicit empty list is meaningful: "the session directory is the project root".
78
+ const markers = Array.isArray(raw.projectRootMarkers)
79
+ ? raw.projectRootMarkers.filter((marker) => typeof marker === 'string' && marker.trim().length > 0)
80
+ : [...DEFAULT_PROJECT_MARKERS]
81
+ const prefix = text(raw.toolPrefix, 'notepad')
82
+ return {
83
+ level: normalizeLevel(raw.level),
84
+ defaultScope: SCOPES.includes(raw.defaultScope) ? raw.defaultScope : 'project',
85
+ dshHome: text(raw.dshHome, ''),
86
+ globalDir: text(raw.globalDir, ''),
87
+ projectDir: text(raw.projectDir, ''),
88
+ fileName: text(raw.fileName, DEFAULT_FILE_NAME),
89
+ projectRootMarkers: markers.length > 0 ? markers : [],
90
+ maxNotes: count(raw.maxNotes, DEFAULT_MAX_NOTES),
91
+ maxContentLength: count(raw.maxContentLength, DEFAULT_MAX_CONTENT_LENGTH),
92
+ defaultLimit: count(raw.defaultLimit, DEFAULT_LIST_LIMIT),
93
+ toolPrefix: /^[a-z][a-z0-9_-]*$/.test(prefix) ? prefix : 'notepad',
94
+ enableCommand: raw.enableCommand !== false,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The session working directory a call belongs to, falling back to the host
100
+ * process directory for a non-agent caller.
101
+ * @param {{ agent?: { session?: { header?: { cwd?: string } } } } | undefined} exec - tool execution context.
102
+ * @returns {string} an absolute working directory.
103
+ */
104
+ function cwdOf(exec) {
105
+ return exec?.agent?.session?.header?.cwd ?? process.cwd()
106
+ }
107
+
108
+ /**
109
+ * The one-line rendering shared by the `/note` command and error text.
110
+ * @param {Array<{ id: string, at: string, scope: string, content: string }>} notes - notes, newest first.
111
+ * @param {number} total - total matching notes before the limit.
112
+ * @returns {string} the rendered listing.
113
+ */
114
+ export function renderNotes(notes, total) {
115
+ if (notes.length === 0) return 'No notes yet.'
116
+ const head = notes.map((note, index) => `${index + 1}. [${note.id}] ${note.at} (${note.scope}) ${note.content}`)
117
+ const tail = notes.length < total ? `\n… ${total - notes.length} older note(s) not shown.` : ''
118
+ return `${head.join('\n')}${tail}`
119
+ }
120
+
121
+ /** The `enabled` clause appended to guidance about a disabled level. */
122
+ function scopeHint(options) {
123
+ return options.level === 'both'
124
+ ? 'Use `global` for cross-project notes or `project` for this project\'s notes.'
125
+ : `This deployment stores notes at the \`${options.level}\` level only.`
126
+ }
127
+
128
+ /**
129
+ * Register the notepad tools, and optionally the `/note` command, on the
130
+ * calling context.
131
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context. `ctx.tools` is required; `ctx.commands` is used when present.
132
+ * @param {Record<string, unknown>} [config] - raw plugin config.
133
+ */
134
+ export function apply(ctx, config = {}) {
135
+ const options = resolveOptions(config)
136
+ /** @type {Map<string, NotepadStore>} per-working-directory stores. */
137
+ const stores = new Map()
138
+ const storeFor = (cwd) => {
139
+ const key = cwd
140
+ let store = stores.get(key)
141
+ if (store === undefined) {
142
+ if (stores.size >= 32) stores.clear()
143
+ store = new NotepadStore({ cwd: key, ...options })
144
+ stores.set(key, store)
145
+ }
146
+ return store
147
+ }
148
+
149
+ ctx.tools.register(defineTool({
150
+ name: `${options.toolPrefix}_add`,
151
+ description: `Append one short note to the notepad so it can be recalled later. ${scopeHint(options)} Notes are stored as JSON Lines, one file per level, and every note gets a timestamp and a short id.`,
152
+ parameters: {
153
+ content: { type: 'string', required: true, description: 'The note text. One self-contained fact, decision, or reminder.' },
154
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional free-form tags used for later filtering.' },
155
+ scope: { type: 'string', enum: [...SCOPES], description: 'Storage level for this note; omitted uses the configured default level.' },
156
+ },
157
+ output: {
158
+ schema: {
159
+ type: 'object',
160
+ additionalProperties: false,
161
+ properties: {
162
+ id: { type: 'string', required: true },
163
+ at: { type: 'string', required: true },
164
+ scope: { type: 'string', required: true, enum: [...SCOPES] },
165
+ tags: { type: 'array', required: true, items: { type: 'string' } },
166
+ content: { type: 'string', required: true },
167
+ file: { type: 'string', required: true },
168
+ total: { type: 'integer', required: true },
169
+ },
170
+ },
171
+ render: (_args, value) => [{
172
+ type: 'text',
173
+ text: `Noted ${value.id} at ${value.at} in the ${value.scope} notepad (${value.total} note(s) there).\nFile: ${value.file}`,
174
+ }],
175
+ },
176
+ async execute(args, exec) {
177
+ const store = storeFor(cwdOf(exec))
178
+ const { note, file, total } = await store.add({ content: args.content, tags: args.tags, scope: args.scope })
179
+ return { ...note, file, total }
180
+ },
181
+ presentCall: (args) => ({
182
+ card: 'generic',
183
+ title: 'Notepad: add note',
184
+ kind: 'other',
185
+ rawInput: typeof args.content === 'string' ? args.content : args,
186
+ }),
187
+ }))
188
+
189
+ ctx.tools.register(defineTool({
190
+ name: `${options.toolPrefix}_list`,
191
+ description: `Read notes back from the notepad, newest first. ${scopeHint(options)} Omit \`scope\` to read every enabled level at once; use \`query\` to filter and \`limit\` to bound the result (0 returns every match).`,
192
+ parameters: {
193
+ scope: { type: 'string', enum: [...SCOPES], description: 'Level to read; omitted reads every enabled level.' },
194
+ query: { type: 'string', description: 'Case-insensitive substring filter over note text, tags, and id.' },
195
+ limit: { type: 'integer', description: `Maximum notes to return; 0 returns every match. Defaults to ${options.defaultLimit}.` },
196
+ },
197
+ output: {
198
+ schema: {
199
+ type: 'object',
200
+ additionalProperties: false,
201
+ properties: {
202
+ scopes: { type: 'array', required: true, items: { type: 'string' } },
203
+ total: { type: 'integer', required: true },
204
+ returned: { type: 'integer', required: true },
205
+ truncated: { type: 'boolean', required: true },
206
+ notes: {
207
+ type: 'array',
208
+ required: true,
209
+ items: {
210
+ type: 'object',
211
+ additionalProperties: false,
212
+ properties: {
213
+ id: { type: 'string', required: true },
214
+ at: { type: 'string', required: true },
215
+ scope: { type: 'string', required: true },
216
+ tags: { type: 'array', required: true, items: { type: 'string' } },
217
+ content: { type: 'string', required: true },
218
+ file: { type: 'string', required: true },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ },
224
+ render: (_args, value) => [{
225
+ type: 'text',
226
+ text: renderNotes(value.notes, value.total),
227
+ }],
228
+ },
229
+ async execute(args, exec) {
230
+ return await storeFor(cwdOf(exec)).list({ scope: args.scope, query: args.query, limit: args.limit })
231
+ },
232
+ presentCall: () => ({ card: 'generic', title: 'Notepad: list notes', kind: 'read' }),
233
+ }))
234
+
235
+ ctx.tools.register(defineTool({
236
+ name: `${options.toolPrefix}_clear`,
237
+ description: `Delete stored notes. ${scopeHint(options)} Destructive: pass \`confirm: true\`, and pass an explicit \`scope\` to clear exactly one level.`,
238
+ parameters: {
239
+ scope: { type: 'string', required: true, enum: [...SCOPES], description: 'The one level to clear.' },
240
+ confirm: { type: 'boolean', required: true, description: 'Must be true; the call is refused otherwise.' },
241
+ },
242
+ output: {
243
+ schema: {
244
+ type: 'object',
245
+ additionalProperties: false,
246
+ properties: {
247
+ scope: { type: 'string', required: true },
248
+ removed: { type: 'integer', required: true },
249
+ file: { type: 'string', required: true },
250
+ },
251
+ },
252
+ render: (_args, value) => [{
253
+ type: 'text',
254
+ text: value.removed === 0
255
+ ? `The ${value.scope} notepad was already empty (${value.file}).`
256
+ : `Cleared ${value.removed} note(s) from the ${value.scope} notepad (${value.file}).`,
257
+ }],
258
+ },
259
+ async execute(args, exec) {
260
+ if (args.confirm !== true) throw new Error('refusing to clear notes: pass `confirm: true` to confirm the deletion')
261
+ const store = storeFor(cwdOf(exec))
262
+ const report = await store.clear({ scope: args.scope })
263
+ const entry = report.files[0]
264
+ return { scope: args.scope, removed: entry?.removed ?? 0, file: entry?.file ?? store.fileFor(args.scope) }
265
+ },
266
+ presentCall: (args) => ({ card: 'generic', title: 'Notepad: clear notes', kind: 'delete', rawInput: args.scope }),
267
+ }))
268
+
269
+ const commands = typeof ctx.get === 'function' ? ctx.get('commands') : ctx.commands
270
+ if (options.enableCommand && commands !== undefined && typeof commands.register === 'function') {
271
+ ctx.effect(() => commands.register({
272
+ name: 'note',
273
+ description: 'Record a quick note, or list the most recent ones',
274
+ input: { hint: '<text> — 留空则列出最近 5 条' },
275
+ handler: async (invocation) => {
276
+ const store = storeFor(invocation.agent?.session?.header?.cwd ?? process.cwd())
277
+ const text = typeof invocation.rawInput === 'string' ? invocation.rawInput.trim() : ''
278
+ if (text.length === 0) {
279
+ const { notes, total } = await store.list({ limit: 5 })
280
+ return { kind: 'success', text: renderNotes(notes, total) }
281
+ }
282
+ const { note, file, total } = await store.add({ content: text })
283
+ return { kind: 'success', text: `Noted ${note.id} (${note.scope}, ${total} note(s)).\nFile: ${file}` }
284
+ },
285
+ }), 'notepad /note command')
286
+ }
287
+
288
+ // The panel's data channel. `webServer` is optional: a profile without a Web
289
+ // carrier (headless, sdk) keeps the tools and the `/note` command only.
290
+ const api = createNotepadApi({
291
+ storeFor,
292
+ listProjects: () => listProjects(ctx),
293
+ // The panel follows the session the user is in: its session-scoped client
294
+ // registration reports the id, and the host resolves that session's cwd to
295
+ // the same project the model tools use.
296
+ resolveSessionCwd: (sessionId) => {
297
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined
298
+ if (agents === undefined || typeof agents.get !== 'function') return undefined
299
+ try {
300
+ return agents.get(sessionId)?.session?.header?.cwd
301
+ } catch {
302
+ return undefined
303
+ }
304
+ },
305
+ // Reuse the host's own browser-session gate: this route shadows the API
306
+ // gateway prefix, so nothing else would authenticate it.
307
+ admit: (request) => {
308
+ const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
309
+ if (connection === undefined || typeof connection.requestRejection !== 'function') return undefined
310
+ try {
311
+ return connection.requestRejection({ headers: request.headers })
312
+ } catch {
313
+ return undefined
314
+ }
315
+ },
316
+ })
317
+ ctx.inject(['webServer'], (routeCtx) => {
318
+ routeCtx.effect(() => routeCtx.webServer.register({
319
+ kind: 'prefix',
320
+ path: API_PREFIX,
321
+ handler: (req, res) => {
322
+ void serveNotepadApi(api, req, res, routeCtx.logger)
323
+ },
324
+ }), 'notepad api route')
325
+ })
326
+ }
327
+
328
+ /**
329
+ * The project directories the panel can target: every registered workspace,
330
+ * most recently updated first so the panel's fallback and its dropdown both
331
+ * prefer the project that was touched last.
332
+ * @param {import('@deepseek-ai/cordis').Context} ctx - host context.
333
+ * @returns {Array<{ path: string, title: string }>} registered workspaces.
334
+ */
335
+ function listProjects(ctx) {
336
+ const registry = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined
337
+ if (registry === undefined || typeof registry.list !== 'function') return []
338
+ try {
339
+ return registry.list()
340
+ .map((workspace) => ({ path: workspace.path, title: workspace.title, updatedAt: workspace.updatedAt }))
341
+ .sort((left, right) => (left.updatedAt === right.updatedAt ? 0 : left.updatedAt < right.updatedAt ? 1 : -1))
342
+ .map(({ path, title }) => ({ path, title }))
343
+ } catch {
344
+ return []
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Read a request body, bounded by {@link MAX_BODY_BYTES}.
350
+ * @param {import('node:http').IncomingMessage} req - the request.
351
+ * @returns {Promise<string>} the body text.
352
+ * @throws {ApiError} 413 when the body exceeds the cap.
353
+ */
354
+ async function readRequestBody(req) {
355
+ let size = 0
356
+ const chunks = []
357
+ for await (const chunk of req) {
358
+ size += chunk.length
359
+ if (size > MAX_BODY_BYTES) throw new ApiError(413, `request body exceeds ${MAX_BODY_BYTES} bytes`)
360
+ chunks.push(chunk)
361
+ }
362
+ return Buffer.concat(chunks).toString('utf8')
363
+ }
364
+
365
+ /**
366
+ * Write one JSON response.
367
+ * @param {import('node:http').ServerResponse} res - the response.
368
+ * @param {number} status - HTTP status.
369
+ * @param {unknown} payload - JSON-serializable body.
370
+ */
371
+ function respondJson(res, status, payload) {
372
+ const text = JSON.stringify(payload ?? null)
373
+ res.writeHead(status, {
374
+ 'content-type': 'application/json; charset=utf-8',
375
+ 'content-length': Buffer.byteLength(text),
376
+ 'cache-control': 'no-store',
377
+ })
378
+ res.end(text)
379
+ }
380
+
381
+ /**
382
+ * Serve one notepad API request over HTTP. The route is same-origin only: it
383
+ * answers no CORS preflight, and the API itself requires {@link CLIENT_HEADER}.
384
+ * @param {ReturnType<typeof createNotepadApi>} api - the API.
385
+ * @param {import('node:http').IncomingMessage} req - the request.
386
+ * @param {import('node:http').ServerResponse} res - the response.
387
+ * @param {{ warn?: (value: unknown) => void }} [logger] - host logger.
388
+ */
389
+ async function serveNotepadApi(api, req, res, logger) {
390
+ try {
391
+ const method = String(req.method ?? 'GET').toUpperCase()
392
+ const url = new URL(req.url ?? '/', 'http://localhost')
393
+ if (url.pathname !== API_PREFIX && !url.pathname.startsWith(`${API_PREFIX}/`)) {
394
+ respondJson(res, 404, { error: `unknown notepad route ${JSON.stringify(url.pathname)}` })
395
+ return
396
+ }
397
+ const query = Object.fromEntries(url.searchParams.entries())
398
+ let body
399
+ if (method !== 'GET' && method !== 'HEAD') {
400
+ const raw = await readRequestBody(req)
401
+ if (raw.trim().length > 0) {
402
+ try {
403
+ body = JSON.parse(raw)
404
+ } catch {
405
+ respondJson(res, 400, { error: 'request body must be valid JSON' })
406
+ return
407
+ }
408
+ }
409
+ }
410
+ const result = await api.handle({ method, pathname: url.pathname, query, headers: req.headers ?? {}, body })
411
+ respondJson(res, result.status, result.body)
412
+ } catch (error) {
413
+ if (error instanceof ApiError) {
414
+ respondJson(res, error.status, { error: error.message })
415
+ return
416
+ }
417
+ logger?.warn?.(error)
418
+ respondJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
419
+ }
420
+ }
421
+
422
+ export { NotepadStore, NotepadError, SCOPES, LEVELS } from './store.js'
423
+ // Exported for tests.
424
+ export { listProjects }
425
+
426
+ // Named exports only, like every first-party DSH plugin. The loader unwraps a
427
+ // module as `exports.default ?? exports`, so an extra default object is only a
428
+ // second, competing plugin shape here.