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/LICENSE +21 -0
- package/README.md +296 -2
- package/cordis.patch.yml +9 -0
- package/lib/api.js +251 -0
- package/lib/client.js +495 -0
- package/lib/index.js +428 -0
- package/lib/store.js +508 -0
- package/package.json +52 -4
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|