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/store.js ADDED
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Note storage for `dsh-plugin-notepad`.
3
+ *
4
+ * Notes are plain JSON Lines (`{"id","at","scope","tags","content"}` per line),
5
+ * one file per level, so the store stays readable and greppable without any
6
+ * database dependency:
7
+ *
8
+ * - `global` → `<dshHome>/notepad/notes.jsonl` — shared by every project.
9
+ * - `project` → `<projectRoot>/.dsh/notepad/notes.jsonl` — one file per project,
10
+ * where `projectRoot` is the nearest ancestor holding a project root marker.
11
+ *
12
+ * Appends are serialized per store instance, so concurrent tool calls cannot
13
+ * interleave a partial line.
14
+ *
15
+ * @module dsh-plugin-notepad/store
16
+ */
17
+ import { existsSync } from 'node:fs'
18
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, join, resolve } from 'node:path'
21
+
22
+ /** The two note levels, in listing order. */
23
+ export const SCOPES = ['global', 'project']
24
+ /** The `level` config values: one level, or both. */
25
+ export const LEVELS = ['global', 'project', 'both']
26
+ /** Default note file name inside a level directory. */
27
+ export const DEFAULT_FILE_NAME = 'notes.jsonl'
28
+ /**
29
+ * Default project-root markers, checked in order at every ancestor. Deliberately
30
+ * excludes `.dsh`: the harness home is itself `<home>/.dsh`, so treating it as a
31
+ * marker would resolve every path under the user's home to the harness home and
32
+ * silently merge project notes into the global level.
33
+ */
34
+ export const DEFAULT_PROJECT_MARKERS = ['.git', '.hg', '.svn']
35
+ /** Default per-file note cap (oldest notes are pruned past it). */
36
+ export const DEFAULT_MAX_NOTES = 1000
37
+ /** Default content length cap, in characters. */
38
+ export const DEFAULT_MAX_CONTENT_LENGTH = 4000
39
+ /** Default `limit` for a listing that omits one. */
40
+ export const DEFAULT_LIST_LIMIT = 20
41
+
42
+ /** A user-facing configuration or input problem. */
43
+ export class NotepadError extends Error {
44
+ /**
45
+ * @param {string} message - the problem, phrased for the caller.
46
+ */
47
+ constructor(message) {
48
+ super(message)
49
+ this.name = 'NotepadError'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Expand the supported tilde prefixes against the operating-system home.
55
+ * @param {string} path - configured path that may begin with `~`, `~/`, or `~\`.
56
+ * @returns {string} the expanded path.
57
+ */
58
+ export function expandHomePath(path) {
59
+ if (path === '~') return homedir()
60
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
61
+ return path
62
+ }
63
+
64
+ /**
65
+ * Resolve the DeepSeek Harness home. Precedence: explicit config, `$DSH_HOME`,
66
+ * then `~/.dsh` — the same order the harness itself uses.
67
+ * @param {string} [configured] - explicit harness-home override.
68
+ * @param {Record<string, string | undefined>} [env] - environment mapping.
69
+ * @returns {string} the absolute harness home.
70
+ */
71
+ export function resolveDshHome(configured, env = process.env) {
72
+ const fromConfig = typeof configured === 'string' && configured.trim().length > 0 ? configured.trim() : undefined
73
+ const fromEnv = typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim().length > 0 ? env.DSH_HOME.trim() : undefined
74
+ return resolve(expandHomePath(fromConfig ?? fromEnv ?? join(homedir(), '.dsh')))
75
+ }
76
+
77
+ /**
78
+ * Find the project root for a session working directory: the nearest ancestor
79
+ * (starting at `cwd`) that contains one of `markers`, else `cwd` itself.
80
+ * @param {string} cwd - the session working directory.
81
+ * @param {readonly string[]} [markers] - project root markers; empty entries are ignored.
82
+ * @param {(path: string) => boolean} [exists] - existence probe, injectable for tests.
83
+ * @returns {string} the absolute project root.
84
+ */
85
+ export function findProjectRoot(cwd, markers = DEFAULT_PROJECT_MARKERS, exists = existsSync) {
86
+ const start = resolve(cwd)
87
+ const wanted = markers.filter((marker) => typeof marker === 'string' && marker.length > 0)
88
+ if (wanted.length === 0) return start
89
+ let current = start
90
+ for (;;) {
91
+ if (wanted.some((marker) => exists(join(current, marker)))) return current
92
+ const parent = dirname(current)
93
+ if (parent === current) return start
94
+ current = parent
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Normalize a configured level.
100
+ * @param {unknown} value - raw level.
101
+ * @returns {'global' | 'project' | 'both'} the accepted level.
102
+ */
103
+ export function normalizeLevel(value) {
104
+ return LEVELS.includes(value) ? value : 'both'
105
+ }
106
+
107
+ /**
108
+ * Normalize a configured default scope.
109
+ * @param {unknown} value - raw scope.
110
+ * @returns {'global' | 'project'} the accepted scope.
111
+ */
112
+ export function normalizeDefaultScope(value) {
113
+ return SCOPES.includes(value) ? value : 'project'
114
+ }
115
+
116
+ /**
117
+ * Resolve the level a single call addresses.
118
+ *
119
+ * An explicit scope must be both a known scope and enabled for this
120
+ * deployment; an omitted scope falls back to the configured default, or to the
121
+ * only enabled level when that default is disabled.
122
+ * @param {object} input - resolution input.
123
+ * @param {string} [input.requested] - the scope the call asked for, if any.
124
+ * @param {'global' | 'project' | 'both'} input.level - enabled level(s).
125
+ * @param {'global' | 'project'} input.defaultScope - preferred omitted scope.
126
+ * @returns {'global' | 'project'} the scope to use.
127
+ * @throws {NotepadError} when an explicit scope is unknown or disabled.
128
+ */
129
+ export function resolveScope({ requested, level, defaultScope }) {
130
+ const enabled = level === 'both' ? [...SCOPES] : [level]
131
+ if (requested === undefined || requested === null || requested === '') {
132
+ return enabled.includes(defaultScope) ? defaultScope : enabled[0]
133
+ }
134
+ if (!SCOPES.includes(requested)) {
135
+ throw new NotepadError(`unknown scope ${JSON.stringify(requested)}: expected one of ${SCOPES.join(', ')}`)
136
+ }
137
+ if (!enabled.includes(requested)) {
138
+ throw new NotepadError(`scope ${JSON.stringify(requested)} is disabled: this notepad exposes ${enabled.join(' + ')} (change \`level\` to enable it)`)
139
+ }
140
+ return requested
141
+ }
142
+
143
+ /**
144
+ * Keep a usable note file name: a bare file name, never a path.
145
+ * @param {unknown} value - configured file name.
146
+ * @returns {string} the accepted file name.
147
+ */
148
+ export function normalizeFileName(value) {
149
+ if (typeof value === 'string') {
150
+ const trimmed = value.trim()
151
+ if (trimmed.length > 0 && !trimmed.includes('/') && !trimmed.includes('\\') && trimmed !== '.' && trimmed !== '..') return trimmed
152
+ }
153
+ return DEFAULT_FILE_NAME
154
+ }
155
+
156
+ /**
157
+ * Accept a non-negative integer count, else a fallback.
158
+ * @param {unknown} value - configured count.
159
+ * @param {number} fallback - value used when the input is not a non-negative integer.
160
+ * @returns {number} the accepted count.
161
+ */
162
+ function normalizeCount(value, fallback) {
163
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback
164
+ }
165
+
166
+ /**
167
+ * Normalize note tags: trimmed, non-empty, de-duplicated strings.
168
+ * @param {unknown} tags - raw tags.
169
+ * @returns {string[]} the accepted tags.
170
+ */
171
+ export function normalizeTags(tags) {
172
+ if (!Array.isArray(tags)) return []
173
+ const seen = new Set()
174
+ for (const tag of tags) {
175
+ if (typeof tag !== 'string') continue
176
+ const trimmed = tag.trim()
177
+ if (trimmed.length > 0) seen.add(trimmed)
178
+ }
179
+ return [...seen]
180
+ }
181
+
182
+ /**
183
+ * Whether a filesystem error means "nothing is stored there yet".
184
+ * @param {unknown} error - a thrown error.
185
+ * @returns {boolean} true for absence errors.
186
+ */
187
+ function isAbsent(error) {
188
+ return typeof error === 'object' && error !== null && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
189
+ }
190
+
191
+ /**
192
+ * Coerce one parsed JSON Lines entry into a note, or `undefined` when the line
193
+ * is not a note (a hand-edited or truncated line is skipped, never fatal).
194
+ * @param {unknown} value - parsed line.
195
+ * @param {'global' | 'project'} scope - level the line was read from.
196
+ * @returns {{ id: string, at: string, scope: string, tags: string[], content: string } | undefined} the note.
197
+ */
198
+ function toNote(value, scope) {
199
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
200
+ if (typeof value.content !== 'string') return undefined
201
+ return {
202
+ id: typeof value.id === 'string' ? value.id : '',
203
+ at: typeof value.at === 'string' ? value.at : '',
204
+ scope: typeof value.scope === 'string' ? value.scope : scope,
205
+ tags: normalizeTags(value.tags),
206
+ content: value.content,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * One note store over a fixed working directory.
212
+ */
213
+ export class NotepadStore {
214
+ /**
215
+ * @param {object} [options] - store configuration.
216
+ * @param {string} [options.cwd] - session working directory (project scope is derived from it).
217
+ * @param {string} [options.dshHome] - harness home override.
218
+ * @param {'global' | 'project' | 'both'} [options.level] - enabled level(s).
219
+ * @param {'global' | 'project'} [options.defaultScope] - scope used when a call omits one.
220
+ * @param {string} [options.globalDir] - global note directory override.
221
+ * @param {string} [options.projectDir] - project note directory override.
222
+ * @param {string} [options.fileName] - note file name inside a level directory.
223
+ * @param {readonly string[]} [options.projectRootMarkers] - project root markers.
224
+ * @param {number} [options.maxNotes] - per-file note cap; 0 disables pruning.
225
+ * @param {number} [options.maxContentLength] - content length cap in characters.
226
+ * @param {number} [options.defaultLimit] - default listing limit.
227
+ */
228
+ constructor(options = {}) {
229
+ this.cwd = resolve(options.cwd ?? process.cwd())
230
+ this.dshHome = resolveDshHome(options.dshHome)
231
+ this.level = normalizeLevel(options.level)
232
+ this.defaultScope = normalizeDefaultScope(options.defaultScope)
233
+ this.fileName = normalizeFileName(options.fileName)
234
+ this.globalDir = options.globalDir ? resolve(expandHomePath(options.globalDir)) : join(this.dshHome, 'notepad')
235
+ this.projectRoot = findProjectRoot(this.cwd, options.projectRootMarkers ?? DEFAULT_PROJECT_MARKERS)
236
+ this.projectDir = options.projectDir ? resolve(expandHomePath(options.projectDir)) : join(this.projectRoot, '.dsh', 'notepad')
237
+ this.maxNotes = normalizeCount(options.maxNotes, DEFAULT_MAX_NOTES)
238
+ this.maxContentLength = normalizeCount(options.maxContentLength, DEFAULT_MAX_CONTENT_LENGTH)
239
+ this.defaultLimit = normalizeCount(options.defaultLimit, DEFAULT_LIST_LIMIT)
240
+ /** @type {Promise<unknown>} tail of the per-store write queue. */
241
+ this.queue = Promise.resolve()
242
+ /** @type {number} per-store id sequence. */
243
+ this.sequence = 0
244
+ }
245
+
246
+ /** The levels this deployment exposes, in listing order. */
247
+ get scopes() {
248
+ return this.level === 'both' ? [...SCOPES] : [this.level]
249
+ }
250
+
251
+ /**
252
+ * The directory holding one level's notes.
253
+ * @param {'global' | 'project'} [scope] - requested level; defaults to the configured default.
254
+ * @returns {string} the absolute directory (not necessarily created yet).
255
+ * @throws {NotepadError} when the scope is unknown or disabled.
256
+ */
257
+ dirFor(scope) {
258
+ const target = resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })
259
+ return target === 'global' ? this.globalDir : this.projectDir
260
+ }
261
+
262
+ /**
263
+ * The note file of one level.
264
+ * @param {'global' | 'project'} [scope] - requested level.
265
+ * @returns {string} the absolute note file path.
266
+ */
267
+ fileFor(scope) {
268
+ return join(this.dirFor(scope), this.fileName)
269
+ }
270
+
271
+ /**
272
+ * Append one note.
273
+ * @param {object} input - note input.
274
+ * @param {string} input.content - note text; required and non-blank.
275
+ * @param {string[]} [input.tags] - optional tags.
276
+ * @param {'global' | 'project'} [input.scope] - target level.
277
+ * @returns {Promise<{ note: { id: string, at: string, scope: string, tags: string[], content: string }, file: string, total: number, pruned: number }>} the recorded note and its postcondition.
278
+ * @throws {NotepadError} when the content is missing or too long.
279
+ */
280
+ add({ content, tags = [], scope } = {}) {
281
+ const text = typeof content === 'string' ? content.trim() : ''
282
+ if (text.length === 0) throw new NotepadError('content must be a non-blank string')
283
+ if (this.maxContentLength > 0 && text.length > this.maxContentLength) {
284
+ throw new NotepadError(`content is ${text.length} characters; the limit for one note is ${this.maxContentLength}`)
285
+ }
286
+ const target = resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })
287
+ const note = {
288
+ id: this.#nextId(target),
289
+ at: new Date().toISOString(),
290
+ scope: target,
291
+ tags: normalizeTags(tags),
292
+ content: text,
293
+ }
294
+ return this.#serialize(async () => {
295
+ const file = this.fileFor(target)
296
+ await mkdir(dirname(file), { recursive: true })
297
+ await appendFile(file, `${JSON.stringify(note)}\n`, 'utf8')
298
+ const pruned = await this.#prune(target)
299
+ const total = (await this.#read(target)).length
300
+ return { note, file, total, pruned }
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Read notes, newest first.
306
+ * @param {object} [query] - listing query.
307
+ * @param {'global' | 'project'} [query.scope] - restrict to one level; omitted reads every enabled level.
308
+ * @param {string} [query.query] - case-insensitive substring filter over content, tags, and id.
309
+ * @param {number} [query.limit] - maximum notes returned; `0` means all, omitted uses the configured default.
310
+ * @returns {Promise<{ scopes: string[], total: number, returned: number, truncated: boolean, notes: Array<{ id: string, at: string, scope: string, tags: string[], content: string, file: string }> }>} the listing.
311
+ */
312
+ async list({ scope, query, limit } = {}) {
313
+ const scopes = scope === undefined || scope === null || scope === ''
314
+ ? this.scopes
315
+ : [resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })]
316
+ /** @type {Array<{ id: string, at: string, scope: string, tags: string[], content: string, file: string }>} */
317
+ let notes = []
318
+ for (const target of scopes) {
319
+ const file = this.fileFor(target)
320
+ for (const note of await this.#read(target)) notes.push({ ...note, file })
321
+ }
322
+ const needle = typeof query === 'string' ? query.trim().toLowerCase() : ''
323
+ if (needle.length > 0) {
324
+ notes = notes.filter((note) =>
325
+ note.content.toLowerCase().includes(needle)
326
+ || note.id.toLowerCase().includes(needle)
327
+ || note.tags.some((tag) => tag.toLowerCase().includes(needle)))
328
+ }
329
+ notes.reverse()
330
+ notes.sort((left, right) => (left.at === right.at ? 0 : left.at < right.at ? 1 : -1))
331
+ const total = notes.length
332
+ const take = limit === 0 ? total : normalizeCount(limit, this.defaultLimit)
333
+ const picked = notes.slice(0, take)
334
+ return { scopes, total, returned: picked.length, truncated: picked.length < total, notes: picked }
335
+ }
336
+
337
+ /**
338
+ * Delete the note file of one level, or of every enabled level.
339
+ * @param {object} [input] - clear input.
340
+ * @param {'global' | 'project'} [input.scope] - level to clear; omitted clears every enabled level.
341
+ * @returns {Promise<{ scopes: string[], removed: number, files: Array<{ scope: string, file: string, removed: number }> }>} the deletion report.
342
+ */
343
+ async clear({ scope } = {}) {
344
+ const scopes = scope === undefined || scope === null || scope === ''
345
+ ? this.scopes
346
+ : [resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })]
347
+ return this.#serialize(async () => {
348
+ const files = []
349
+ let removed = 0
350
+ for (const target of scopes) {
351
+ const file = this.fileFor(target)
352
+ const count = (await this.#read(target)).length
353
+ await rm(file, { force: true })
354
+ files.push({ scope: target, file, removed: count })
355
+ removed += count
356
+ }
357
+ return { scopes, removed, files }
358
+ })
359
+ }
360
+
361
+ /**
362
+ * Rewrite one note in place, keeping its id and creation time.
363
+ * @param {object} input - edit input.
364
+ * @param {'global' | 'project'} [input.scope] - level holding the note.
365
+ * @param {string} input.id - id of the note to edit.
366
+ * @param {string} [input.content] - replacement text; omitted keeps the current text.
367
+ * @param {string[]} [input.tags] - replacement tags; omitted keeps the current tags.
368
+ * @returns {Promise<{ note: { id: string, at: string, scope: string, tags: string[], content: string, editedAt: string }, file: string, total: number }>} the updated note.
369
+ * @throws {NotepadError} when the id is unknown or the new text is unusable.
370
+ */
371
+ async update({ scope, id, content, tags } = {}) {
372
+ if (typeof id !== 'string' || id.trim().length === 0) throw new NotepadError('id must be a non-blank string')
373
+ const target = resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })
374
+ let replacement
375
+ if (content !== undefined) {
376
+ const text = typeof content === 'string' ? content.trim() : ''
377
+ if (text.length === 0) throw new NotepadError('content must be a non-blank string')
378
+ if (this.maxContentLength > 0 && text.length > this.maxContentLength) {
379
+ throw new NotepadError(`content is ${text.length} characters; the limit for one note is ${this.maxContentLength}`)
380
+ }
381
+ replacement = text
382
+ }
383
+ const nextTags = tags === undefined ? undefined : normalizeTags(tags)
384
+ return this.#serialize(async () => {
385
+ const notes = await this.#read(target)
386
+ const index = notes.findIndex((note) => note.id === id)
387
+ if (index < 0) throw new NotepadError(`no note ${JSON.stringify(id)} in the ${target} level`)
388
+ const previous = notes[index]
389
+ const note = {
390
+ ...previous,
391
+ content: replacement ?? previous.content,
392
+ tags: nextTags ?? previous.tags,
393
+ editedAt: new Date().toISOString(),
394
+ }
395
+ notes[index] = note
396
+ const file = await this.#rewrite(target, notes)
397
+ return { note, file, total: notes.length }
398
+ })
399
+ }
400
+
401
+ /**
402
+ * Delete one note by id.
403
+ * @param {object} input - delete input.
404
+ * @param {'global' | 'project'} [input.scope] - level holding the note.
405
+ * @param {string} input.id - id of the note to delete.
406
+ * @returns {Promise<{ removed: number, file: string, total: number, note: object | undefined }>} the deletion report.
407
+ */
408
+ async remove({ scope, id } = {}) {
409
+ if (typeof id !== 'string' || id.trim().length === 0) throw new NotepadError('id must be a non-blank string')
410
+ const target = resolveScope({ requested: scope, level: this.level, defaultScope: this.defaultScope })
411
+ return this.#serialize(async () => {
412
+ const notes = await this.#read(target)
413
+ const index = notes.findIndex((note) => note.id === id)
414
+ const file = this.fileFor(target)
415
+ if (index < 0) return { removed: 0, file, total: notes.length, note: undefined }
416
+ const [note] = notes.splice(index, 1)
417
+ await this.#rewrite(target, notes)
418
+ return { removed: 1, file, total: notes.length, note }
419
+ })
420
+ }
421
+
422
+ /**
423
+ * Atomically rewrite one level's note file.
424
+ * @param {'global' | 'project'} scope - level to write.
425
+ * @param {Array<object>} notes - the complete note list, in file order.
426
+ * @returns {Promise<string>} the written file path.
427
+ */
428
+ async #rewrite(scope, notes) {
429
+ const file = this.fileFor(scope)
430
+ await mkdir(dirname(file), { recursive: true })
431
+ const temporary = `${file}.tmp`
432
+ await writeFile(temporary, notes.map((note) => `${JSON.stringify(note)}\n`).join(''), 'utf8')
433
+ await rename(temporary, file)
434
+ return file
435
+ }
436
+
437
+ /**
438
+ * Run one operation after every earlier operation on this store settled.
439
+ * @template T
440
+ * @param {() => Promise<T>} operation - the queued operation.
441
+ * @returns {Promise<T>} the operation's own outcome.
442
+ */
443
+ #serialize(operation) {
444
+ const run = this.queue.then(operation, operation)
445
+ this.queue = run.then(() => undefined, () => undefined)
446
+ return run
447
+ }
448
+
449
+ /**
450
+ * Read one level's notes in file order (oldest first).
451
+ * @param {'global' | 'project'} scope - level to read.
452
+ * @returns {Promise<Array<{ id: string, at: string, scope: string, tags: string[], content: string }>>} parsed notes.
453
+ */
454
+ async #read(scope) {
455
+ const file = this.fileFor(scope)
456
+ let raw
457
+ try {
458
+ raw = await readFile(file, 'utf8')
459
+ } catch (error) {
460
+ if (isAbsent(error)) return []
461
+ throw error
462
+ }
463
+ const notes = []
464
+ for (const line of raw.split('\n')) {
465
+ const trimmed = line.trim()
466
+ if (trimmed.length === 0) continue
467
+ let parsed
468
+ try {
469
+ parsed = JSON.parse(trimmed)
470
+ } catch {
471
+ continue
472
+ }
473
+ const note = toNote(parsed, scope)
474
+ if (note !== undefined) notes.push(note)
475
+ }
476
+ return notes
477
+ }
478
+
479
+ /**
480
+ * Enforce the per-file note cap by rewriting the newest `maxNotes` entries.
481
+ * @param {'global' | 'project'} scope - level to prune.
482
+ * @returns {Promise<number>} the number of pruned notes.
483
+ */
484
+ async #prune(scope) {
485
+ if (this.maxNotes <= 0) return 0
486
+ const notes = await this.#read(scope)
487
+ if (notes.length <= this.maxNotes) return 0
488
+ const kept = notes.slice(notes.length - this.maxNotes)
489
+ const file = this.fileFor(scope)
490
+ const temporary = `${file}.tmp`
491
+ await writeFile(temporary, kept.map((note) => `${JSON.stringify(note)}\n`).join(''), 'utf8')
492
+ await rename(temporary, file)
493
+ return notes.length - kept.length
494
+ }
495
+
496
+ /**
497
+ * Mint a short, readable, collision-resistant note id.
498
+ * @param {'global' | 'project'} scope - level the note lands in.
499
+ * @returns {string} the new id, for example `p-lx9c2k-01ab3f`.
500
+ */
501
+ #nextId(scope) {
502
+ this.sequence += 1
503
+ const time = Date.now().toString(36)
504
+ const sequence = this.sequence.toString(36).padStart(2, '0')
505
+ const random = Math.random().toString(36).slice(2, 6).padEnd(4, '0')
506
+ return `${scope === 'global' ? 'g' : 'p'}-${time}-${sequence}${random}`
507
+ }
508
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "dsh-plugin-notepad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Simple notepad plugin for DeepSeek Harness: record short notes in a global ($DSH_HOME) or project (<projectRoot>/.dsh) level",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./store": "./lib/store.js",
10
+ "./api": "./lib/api.js",
11
+ "./client": "./lib/client.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test"
22
+ },
23
+ "keywords": [
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "deepseek-harness",
27
+ "notepad",
28
+ "notes",
29
+ "cordis"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/qux-bbb/dsh-plugin-notepad.git"
35
+ },
36
+ "homepage": "https://github.com/qux-bbb/dsh-plugin-notepad#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/qux-bbb/dsh-plugin-notepad/issues"
39
+ },
40
+ "peerDependencies": {
41
+ "@deepseek-ai/cordis": "*",
42
+ "@deepseek-ai/dsh-tools": "*",
43
+ "@deepseek-ai/schemastery": "*"
44
+ },
45
+ "dsh": {
46
+ "manifestVersion": 1,
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "platform": "web"
52
+ }
53
+ }
54
+ }