pinokiod 7.2.17 → 7.2.18

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.
Files changed (51) hide show
  1. package/kernel/agent_instructions.js +166 -0
  2. package/kernel/api/index.js +137 -12
  3. package/kernel/bin/huggingface.js +1 -1
  4. package/kernel/environment.js +23 -9
  5. package/kernel/plugin_sources.js +57 -4
  6. package/kernel/prototype.js +4 -0
  7. package/kernel/shell.js +2 -0
  8. package/kernel/watch/index.js +31 -4
  9. package/package.json +1 -1
  10. package/server/features/index.js +4 -4
  11. package/server/features/{drafts → notes}/index.js +9 -9
  12. package/server/features/{drafts → notes}/parser.js +12 -7
  13. package/server/features/notes/public/notes.css +955 -0
  14. package/server/features/notes/public/notes.js +1149 -0
  15. package/server/features/{drafts → notes}/registry_import.js +22 -22
  16. package/server/features/notes/routes.js +156 -0
  17. package/server/features/notes/service.js +326 -0
  18. package/server/features/{drafts → notes}/watcher.js +14 -16
  19. package/server/index.js +61 -30
  20. package/server/lib/content_validation.js +19 -8
  21. package/server/lib/workspace_catalog.js +18 -18
  22. package/server/public/task-launcher.css +11 -3
  23. package/server/public/tasker.css +336 -0
  24. package/server/public/tasker.js +407 -0
  25. package/server/views/d.ejs +33 -2
  26. package/server/views/partials/menu.ejs +1 -1
  27. package/server/views/partials/workspace_row.ejs +11 -11
  28. package/server/views/pre.ejs +1 -1
  29. package/server/views/task_launch.ejs +10 -10
  30. package/server/views/tasker.ejs +40 -0
  31. package/server/views/terminal.ejs +15 -6
  32. package/server/views/terminals.ejs +0 -1
  33. package/server/views/workspaces.ejs +2 -1
  34. package/system/plugin/antigravity/pinokio.js +2 -4
  35. package/system/plugin/claude/pinokio.js +2 -4
  36. package/system/plugin/claude-auto/pinokio.js +2 -4
  37. package/system/plugin/claude-desktop/pinokio.js +2 -4
  38. package/system/plugin/codex/pinokio.js +2 -4
  39. package/system/plugin/codex-auto/pinokio.js +2 -4
  40. package/system/plugin/codex-desktop/pinokio.js +2 -4
  41. package/system/plugin/crush/pinokio.js +2 -4
  42. package/system/plugin/cursor/pinokio.js +2 -4
  43. package/system/plugin/gemini/pinokio.js +2 -4
  44. package/system/plugin/gemini-auto/pinokio.js +2 -4
  45. package/system/plugin/qwen/pinokio.js +2 -4
  46. package/system/plugin/vscode/pinokio.js +2 -4
  47. package/system/plugin/windsurf/pinokio.js +2 -4
  48. package/test/plugin-sources.test.js +45 -0
  49. package/server/features/drafts/public/drafts.js +0 -1504
  50. package/server/features/drafts/routes.js +0 -68
  51. package/server/features/drafts/service.js +0 -261
@@ -145,10 +145,10 @@ function buildAuthorizeUrl(req, registryBase, item, bundle) {
145
145
  return authorizeUrl
146
146
  }
147
147
 
148
- async function findDraftById(drafts, id) {
148
+ async function findNoteById(notes, id) {
149
149
  const normalized = String(id || "").trim()
150
150
  if (!normalized) return null
151
- const items = await drafts.listPending({})
151
+ const items = await notes.listPending({})
152
152
  return (items || []).find((item) => item && item.id === normalized) || null
153
153
  }
154
154
 
@@ -168,9 +168,9 @@ function normalizeParent(parent) {
168
168
  }
169
169
 
170
170
  async function buildDraftBundle(item, query = {}) {
171
- const markdown = await fs.promises.readFile(item.postPath, "utf8")
172
- const resultDir = path.dirname(item.postPath)
173
- const titleFallback = item.title || (item.workspaceName ? `Draft for ${item.workspaceName}` : "Draft")
171
+ const markdown = await fs.promises.readFile(item.notePath, "utf8")
172
+ const resultDir = path.dirname(item.notePath)
173
+ const titleFallback = item.title || (item.workspaceName ? `Note for ${item.workspaceName}` : "Note")
174
174
  const extracted = extractTitleAndBody(markdown, titleFallback)
175
175
  const metadataTitle = item.metadata && typeof item.metadata.title === "string"
176
176
  ? normalizeTitle(item.metadata.title)
@@ -195,17 +195,17 @@ function preflightBundle(bundle, options = {}) {
195
195
  const maxFiles = Number(options.maxFiles || DEFAULT_MAX_FILES)
196
196
  const maxFileBytes = Number(options.maxFileBytes || DEFAULT_MAX_FILE_BYTES)
197
197
  if (!bundle.title) {
198
- return "Draft title is missing."
198
+ return "Note title is missing."
199
199
  }
200
200
  if (!isRegistryPostPublish(bundle.publish)) {
201
- return "This draft is not configured for registry publishing."
201
+ return "This note is not configured for registry publishing."
202
202
  }
203
203
  if (bundle.media.length > maxFiles) {
204
- return `Draft has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
204
+ return `Note has ${bundle.media.length} media files. The registry limit is ${maxFiles}.`
205
205
  }
206
206
  const missing = bundle.media.filter((item) => !item.exists)
207
207
  if (missing.length > 0) {
208
- return `Draft references missing media: ${missing.map((item) => item.ref).join(", ")}`
208
+ return `Note references missing media: ${missing.map((item) => item.ref).join(", ")}`
209
209
  }
210
210
  const oversized = bundle.media.find((item) => item.bytes > maxFileBytes)
211
211
  if (oversized) {
@@ -268,10 +268,10 @@ async function uploadBundle(registryBase, token, bundle) {
268
268
  return response.data || {}
269
269
  }
270
270
 
271
- async function uploadDraftFromRequest(drafts, query, token, registryBase, options = {}) {
272
- const item = await findDraftById(drafts, query.draft)
271
+ async function uploadDraftFromRequest(notes, query, token, registryBase, options = {}) {
272
+ const item = await findNoteById(notes, query.draft)
273
273
  if (!item) {
274
- const error = new Error("The local draft is no longer available.")
274
+ const error = new Error("The local note is no longer available.")
275
275
  error.status = 404
276
276
  throw error
277
277
  }
@@ -286,17 +286,17 @@ async function uploadDraftFromRequest(drafts, query, token, registryBase, option
286
286
  }
287
287
 
288
288
  function registerDraftImportRoutes(app, options = {}) {
289
- const drafts = options.drafts
290
- if (!drafts) {
291
- throw new Error("drafts is required")
289
+ const notes = options.notes
290
+ if (!notes) {
291
+ throw new Error("notes is required")
292
292
  }
293
293
  const defaultRegistryUrl = options.defaultRegistryUrl || "https://beta.pinokio.co"
294
294
  const router = express.Router()
295
295
 
296
296
  router.get("/registry/draft-import/authorize-url", asyncHandler(async (req, res) => {
297
- const item = await findDraftById(drafts, req.query.draft)
297
+ const item = await findNoteById(notes, req.query.draft)
298
298
  if (!item) {
299
- return res.status(404).json({ error: "The local draft is no longer available." })
299
+ return res.status(404).json({ error: "The local note is no longer available." })
300
300
  }
301
301
  const bundle = await buildDraftBundle(item, req.query)
302
302
  const problem = preflightBundle(bundle, options)
@@ -316,14 +316,14 @@ function registerDraftImportRoutes(app, options = {}) {
316
316
  }))
317
317
 
318
318
  router.get("/registry/draft-import/start", asyncHandler(async (req, res) => {
319
- const item = await findDraftById(drafts, req.query.draft)
319
+ const item = await findNoteById(notes, req.query.draft)
320
320
  if (!item) {
321
- return renderMessage(res, 404, "Draft not found", "The local draft is no longer available.")
321
+ return renderMessage(res, 404, "Note not found", "The local note is no longer available.")
322
322
  }
323
323
  const bundle = await buildDraftBundle(item, req.query)
324
324
  const problem = preflightBundle(bundle, options)
325
325
  if (problem) {
326
- return renderMessage(res, 400, "Draft is not ready", problem)
326
+ return renderMessage(res, 400, "Note is not ready", problem)
327
327
  }
328
328
  const registryBase = normalizeRegistryBase(req.query.registry, defaultRegistryUrl)
329
329
  if (!registryBase) {
@@ -354,7 +354,7 @@ function registerDraftImportRoutes(app, options = {}) {
354
354
  app: req.body && req.body.app ? String(req.body.app) : ""
355
355
  })
356
356
  const result = await uploadDraftFromRequest(
357
- drafts,
357
+ notes,
358
358
  { draft: req.body && req.body.draft, app: req.body && req.body.app },
359
359
  token,
360
360
  registryBase,
@@ -392,7 +392,7 @@ function registerDraftImportRoutes(app, options = {}) {
392
392
  return renderMessage(res, 400, "Registry unavailable", "The registry URL is invalid.")
393
393
  }
394
394
  try {
395
- const result = await uploadDraftFromRequest(drafts, req.query, token, registryBase, options)
395
+ const result = await uploadDraftFromRequest(notes, req.query, token, registryBase, options)
396
396
  if (result && result.editUrl) {
397
397
  return res.redirect(String(result.editUrl))
398
398
  }
@@ -0,0 +1,156 @@
1
+ const express = require("express")
2
+ const path = require("path")
3
+ const registerDraftImportRoutes = require("./registry_import")
4
+
5
+ function asyncHandler(fn) {
6
+ return (req, res, next) => {
7
+ Promise.resolve(fn(req, res, next)).catch(next)
8
+ }
9
+ }
10
+
11
+ function isInside(candidate, parent) {
12
+ const relative = path.relative(parent, candidate)
13
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
14
+ }
15
+
16
+ function parsePublishConfig(value) {
17
+ if (typeof value !== "string" || !value.trim()) {
18
+ return null
19
+ }
20
+ let parsed
21
+ try {
22
+ parsed = JSON.parse(value)
23
+ } catch (_) {
24
+ return null
25
+ }
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
27
+ return null
28
+ }
29
+ const target = String(parsed.target || "").trim().toLowerCase()
30
+ const type = String(parsed.type || "post").trim().toLowerCase()
31
+ if (target !== "registry" || type !== "post") {
32
+ return null
33
+ }
34
+ const publish = { target: "registry", type: "post" }
35
+ const parent = parsed.parent && typeof parsed.parent === "object" && !Array.isArray(parsed.parent)
36
+ ? parsed.parent
37
+ : null
38
+ const parentType = parent ? String(parent.type || "").trim().toLowerCase() : ""
39
+ const parentUrl = parent ? String(parent.url || parent.repoUrl || "").trim() : ""
40
+ if (parentType === "app" && parentUrl) {
41
+ publish.parent = { type: "app", url: parentUrl }
42
+ }
43
+ return publish
44
+ }
45
+
46
+ function registerNoteRoutes(app, options = {}) {
47
+ const notes = options.notes
48
+ if (!notes) {
49
+ throw new Error("notes is required")
50
+ }
51
+ const kernel = options.kernel
52
+
53
+ const router = express.Router()
54
+ router.get("/notes", asyncHandler(async (req, res) => {
55
+ const cwd = typeof req.query.cwd === "string" ? req.query.cwd : ""
56
+ if (cwd && typeof notes.inspectWorkspace === "function") {
57
+ const resolvedCwd = path.resolve(cwd)
58
+ const home = kernel && kernel.homedir ? path.resolve(kernel.homedir) : ""
59
+ if (!home || isInside(resolvedCwd, home)) {
60
+ const publish = parsePublishConfig(req.query.publish)
61
+ const note = publish ? { publish } : undefined
62
+ await notes.inspectWorkspace({ cwd: resolvedCwd, note }).catch(() => null)
63
+ }
64
+ }
65
+ const items = await notes.listPending({ cwd })
66
+ res.json({
67
+ ok: true,
68
+ items
69
+ })
70
+ }))
71
+
72
+ router.get("/notes/:id/media/:index", asyncHandler(async (req, res) => {
73
+ const item = typeof notes.getPendingById === "function"
74
+ ? await notes.getPendingById(req.params.id)
75
+ : null
76
+ if (!item) {
77
+ res.status(404).send("Note not found")
78
+ return
79
+ }
80
+ const index = Number(req.params.index)
81
+ const media = Array.isArray(item.media) && Number.isInteger(index)
82
+ ? item.media[index]
83
+ : null
84
+ if (!media || !media.exists || !media.path) {
85
+ res.status(404).send("Media not found")
86
+ return
87
+ }
88
+ const filePath = path.resolve(media.path)
89
+ const basePath = path.resolve(item.resultDir)
90
+ const relative = path.relative(basePath, filePath)
91
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
92
+ res.status(403).send("Media path is outside the note")
93
+ return
94
+ }
95
+ res.setHeader("Cache-Control", "no-store")
96
+ res.sendFile(filePath)
97
+ }))
98
+
99
+ router.put("/notes/:id", express.text({ type: "*/*", limit: "6mb" }), asyncHandler(async (req, res) => {
100
+ if (typeof notes.savePendingById !== "function") {
101
+ res.status(501).json({ ok: false, error: "Note editing is unavailable." })
102
+ return
103
+ }
104
+ const markdown = typeof req.body === "string"
105
+ ? req.body
106
+ : (req.body && typeof req.body.markdown === "string"
107
+ ? req.body.markdown
108
+ : null)
109
+ if (markdown === null) {
110
+ res.status(400).json({ ok: false, error: "markdown is required" })
111
+ return
112
+ }
113
+ const revision = typeof req.get("x-pinokio-note-revision") === "string"
114
+ ? req.get("x-pinokio-note-revision")
115
+ : (req.body && typeof req.body.revision === "string"
116
+ ? req.body.revision
117
+ : "")
118
+ try {
119
+ const item = await notes.savePendingById(req.params.id, { markdown, revision })
120
+ if (!item) {
121
+ res.status(404).json({ ok: false, error: "Note not found" })
122
+ return
123
+ }
124
+ res.json({ ok: true, item })
125
+ } catch (error) {
126
+ if (error && error.code === "NOTE_CONFLICT") {
127
+ res.status(409).json({
128
+ ok: false,
129
+ error: error.message,
130
+ item: error.item || null
131
+ })
132
+ return
133
+ }
134
+ if (error && (error.code === "NOTE_TOO_LARGE" || error.code === "NOTE_INVALID_PATH")) {
135
+ res.status(400).json({ ok: false, error: error.message })
136
+ return
137
+ }
138
+ throw error
139
+ }
140
+ }))
141
+
142
+ router.get("/notes.js", (req, res) => {
143
+ res.setHeader("Cache-Control", "no-store")
144
+ res.sendFile(path.resolve(__dirname, "public", "notes.js"))
145
+ })
146
+
147
+ router.get("/notes.css", (req, res) => {
148
+ res.setHeader("Cache-Control", "no-store")
149
+ res.sendFile(path.resolve(__dirname, "public", "notes.css"))
150
+ })
151
+
152
+ app.use(router)
153
+ registerDraftImportRoutes(app, options)
154
+ }
155
+
156
+ module.exports = registerNoteRoutes
@@ -0,0 +1,326 @@
1
+ const fs = require("fs")
2
+ const path = require("path")
3
+ const crypto = require("crypto")
4
+ const {
5
+ RESULT_RELATIVE_DIR,
6
+ NOTE_FILENAME,
7
+ METADATA_FILENAME,
8
+ buildExcerpt,
9
+ describeMediaRefs,
10
+ extractTitle,
11
+ parseNoteMetadata
12
+ } = require("./parser")
13
+
14
+ const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
15
+
16
+ function createHash(value) {
17
+ return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24)
18
+ }
19
+
20
+ function clonePlainObject(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
22
+ return null
23
+ }
24
+ try {
25
+ return JSON.parse(JSON.stringify(value))
26
+ } catch (_) {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function normalizeRelativePath(value, fallback) {
32
+ const raw = String(value || fallback || "").trim().replace(/\\/g, "/")
33
+ if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
34
+ return fallback
35
+ }
36
+ const normalized = path.posix.normalize(raw)
37
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
38
+ return fallback
39
+ }
40
+ return normalized
41
+ }
42
+
43
+ function isInside(candidate, parent) {
44
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate))
45
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
46
+ }
47
+
48
+ function normalizeNoteConfig(config = {}) {
49
+ const params = config && typeof config === "object" ? config : {}
50
+ return {
51
+ path: normalizeRelativePath(params.path, RESULT_RELATIVE_DIR),
52
+ content: normalizeRelativePath(params.content, ""),
53
+ ready: normalizeRelativePath(params.ready, METADATA_FILENAME),
54
+ description: typeof params.description === "string" ? params.description.trim() : "",
55
+ publish: clonePlainObject(params.publish)
56
+ }
57
+ }
58
+
59
+ async function findMetadataFiles(rootDir, filename) {
60
+ const root = path.resolve(rootDir)
61
+ const stats = await fs.promises.stat(root).catch(() => null)
62
+ if (!stats || !stats.isDirectory()) {
63
+ return []
64
+ }
65
+ const target = String(filename || METADATA_FILENAME)
66
+ const results = []
67
+ const visit = async (dir) => {
68
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => null)
69
+ if (!entries) return
70
+ for (const entry of entries) {
71
+ const fullPath = path.resolve(dir, entry.name)
72
+ if (entry.isDirectory()) {
73
+ await visit(fullPath)
74
+ } else if (entry.isFile() && entry.name === target) {
75
+ results.push(fullPath)
76
+ }
77
+ }
78
+ }
79
+ await visit(root)
80
+ return results
81
+ }
82
+
83
+ function createNoteService({ kernel, taskWorkspaceLinks } = {}) {
84
+ if (!kernel) {
85
+ throw new Error("kernel is required")
86
+ }
87
+
88
+ const resultsByBundle = new Map()
89
+
90
+ async function readMarkdown(notePath, stats) {
91
+ const noteStats = stats || await fs.promises.stat(notePath)
92
+ if (noteStats.size > MAX_MARKDOWN_BYTES) {
93
+ throw new Error(`note markdown is too large (${noteStats.size} bytes)`)
94
+ }
95
+ return fs.promises.readFile(notePath, "utf8")
96
+ }
97
+
98
+ async function readNoteMetadata(metadataPath) {
99
+ if (path.extname(metadataPath).toLowerCase() !== ".json") {
100
+ return {}
101
+ }
102
+ const raw = await fs.promises.readFile(metadataPath, "utf8")
103
+ return parseNoteMetadata(raw)
104
+ }
105
+
106
+ async function inspectNoteBundle({ taskId, ref, cwd, note, bundlePath, metadataPath } = {}) {
107
+ if (typeof cwd !== "string" || !cwd.trim()) {
108
+ return null
109
+ }
110
+ const workspacePath = path.resolve(cwd.trim())
111
+ const noteConfig = normalizeNoteConfig(note)
112
+ const rootDir = path.resolve(workspacePath, noteConfig.path)
113
+ const resultDir = path.resolve(bundlePath || rootDir)
114
+ const readyPath = path.resolve(metadataPath || path.resolve(resultDir, noteConfig.ready))
115
+ const readyStats = await fs.promises.stat(readyPath).catch(() => null)
116
+ if (!readyStats || !readyStats.isFile()) {
117
+ resultsByBundle.delete(resultDir)
118
+ return null
119
+ }
120
+ let metadata = {}
121
+ try {
122
+ metadata = await readNoteMetadata(readyPath)
123
+ } catch (_) {
124
+ resultsByBundle.delete(resultDir)
125
+ return null
126
+ }
127
+
128
+ const contentPath = normalizeRelativePath(metadata.content || noteConfig.content, NOTE_FILENAME)
129
+ const notePath = path.resolve(resultDir, contentPath)
130
+ const noteStats = await fs.promises.stat(notePath).catch(() => null)
131
+ if (!noteStats || !noteStats.isFile()) {
132
+ resultsByBundle.delete(resultDir)
133
+ return null
134
+ }
135
+
136
+ const markdown = await readMarkdown(notePath, noteStats)
137
+ const workspaceName = path.basename(workspacePath)
138
+ const bundleName = path.basename(resultDir)
139
+ const media = await describeMediaRefs(markdown, resultDir)
140
+ const updatedAtMs = Math.max(readyStats.mtimeMs || 0, noteStats.mtimeMs || 0)
141
+ const id = createHash(`${workspacePath}|${resultDir}|${notePath}|${readyPath}`)
142
+ const previous = resultsByBundle.get(resultDir) || null
143
+ const publish = noteConfig.publish || (previous && previous.publish) || null
144
+ const description = noteConfig.description || (previous && previous.description) || ""
145
+ const mediaRevision = media
146
+ .map((item) => `${item.ref}:${item.exists ? item.bytes : "missing"}:${item.mtimeMs || 0}`)
147
+ .join("|")
148
+ const revision = createHash(`${noteStats.size}|${noteStats.mtimeMs}|${readyStats.size}|${readyStats.mtimeMs}|${mediaRevision}`)
149
+ const result = {
150
+ id,
151
+ revision,
152
+ taskId,
153
+ ref,
154
+ cwd: workspacePath,
155
+ workspaceName,
156
+ bundleName,
157
+ title: metadata.title || extractTitle(markdown, bundleName || workspaceName),
158
+ markdown,
159
+ excerpt: buildExcerpt(markdown),
160
+ resultDir,
161
+ watchRoot: rootDir,
162
+ notePath,
163
+ contentPath: notePath,
164
+ readyPath,
165
+ metadataPath: readyPath,
166
+ metadata,
167
+ publish,
168
+ description,
169
+ noteBytes: noteStats.size,
170
+ media: media.map((item, index) => ({
171
+ index,
172
+ ref: item.ref,
173
+ path: item.path,
174
+ bytes: item.bytes,
175
+ mtimeMs: item.mtimeMs,
176
+ exists: item.exists,
177
+ ext: path.extname(item.ref || "").toLowerCase()
178
+ })),
179
+ mediaCount: media.length,
180
+ missingMediaCount: media.filter((item) => !item.exists).length,
181
+ mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
182
+ updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
183
+ }
184
+ resultsByBundle.set(resultDir, result)
185
+ return result
186
+ }
187
+
188
+ async function inspectWorkspace({ taskId, ref, cwd, note } = {}) {
189
+ if (typeof cwd !== "string" || !cwd.trim()) {
190
+ return null
191
+ }
192
+ const workspacePath = path.resolve(cwd.trim())
193
+ const noteConfig = normalizeNoteConfig(note)
194
+ const rootDir = path.resolve(workspacePath, noteConfig.path)
195
+ const metadataFiles = await findMetadataFiles(rootDir, noteConfig.ready)
196
+ const seen = new Set()
197
+ const results = []
198
+ for (const metadataPath of metadataFiles) {
199
+ const bundlePath = path.dirname(metadataPath)
200
+ const result = await inspectNoteBundle({
201
+ taskId,
202
+ ref,
203
+ cwd: workspacePath,
204
+ note: noteConfig,
205
+ bundlePath,
206
+ metadataPath
207
+ })
208
+ if (result) {
209
+ seen.add(result.resultDir)
210
+ results.push(result)
211
+ }
212
+ }
213
+ for (const [bundlePath, result] of Array.from(resultsByBundle.entries())) {
214
+ if (result && result.cwd === workspacePath && result.watchRoot === rootDir && !seen.has(bundlePath)) {
215
+ resultsByBundle.delete(bundlePath)
216
+ }
217
+ }
218
+ return results[0] || null
219
+ }
220
+
221
+ async function trackWorkspace({ taskId, ref, cwd } = {}) {
222
+ let resolvedCwd = cwd
223
+ if (!resolvedCwd && ref && taskWorkspaceLinks && typeof taskWorkspaceLinks.resolveWorkspaceRef === "function") {
224
+ resolvedCwd = taskWorkspaceLinks.resolveWorkspaceRef(ref)
225
+ }
226
+ if (!resolvedCwd) {
227
+ return null
228
+ }
229
+ return inspectWorkspace({ taskId, ref, cwd: resolvedCwd })
230
+ }
231
+
232
+ async function listPending(options = {}) {
233
+ const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
234
+ ? path.resolve(options.cwd.trim())
235
+ : ""
236
+ return Array.from(resultsByBundle.values())
237
+ .filter((result) => !filterCwd || result.cwd === filterCwd)
238
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
239
+ }
240
+
241
+ async function getPendingById(id) {
242
+ const normalizedId = typeof id === "string" ? id.trim() : ""
243
+ if (!normalizedId) {
244
+ return null
245
+ }
246
+ return Array.from(resultsByBundle.values()).find((item) => item.id === normalizedId) || null
247
+ }
248
+
249
+ async function refreshPendingItem(item) {
250
+ if (!item || !item.cwd || !item.resultDir || !item.metadataPath) {
251
+ return null
252
+ }
253
+ return inspectNoteBundle({
254
+ taskId: item.taskId,
255
+ ref: item.ref,
256
+ cwd: item.cwd,
257
+ note: {
258
+ description: item.description,
259
+ publish: item.publish
260
+ },
261
+ bundlePath: item.resultDir,
262
+ metadataPath: item.metadataPath
263
+ })
264
+ }
265
+
266
+ async function savePendingById(id, options = {}) {
267
+ const item = await getPendingById(id)
268
+ if (!item) {
269
+ return null
270
+ }
271
+ const markdown = typeof options.markdown === "string" ? options.markdown : null
272
+ if (markdown === null) {
273
+ throw new Error("markdown is required")
274
+ }
275
+ if (Buffer.byteLength(markdown, "utf8") > MAX_MARKDOWN_BYTES) {
276
+ const error = new Error(`note markdown is too large; limit is ${MAX_MARKDOWN_BYTES} bytes`)
277
+ error.code = "NOTE_TOO_LARGE"
278
+ throw error
279
+ }
280
+
281
+ const current = await refreshPendingItem(item)
282
+ if (!current) {
283
+ return null
284
+ }
285
+ const expectedRevision = typeof options.revision === "string" ? options.revision.trim() : ""
286
+ if (expectedRevision && current.revision !== expectedRevision) {
287
+ const error = new Error("Note changed on disk. Reload it before saving.")
288
+ error.code = "NOTE_CONFLICT"
289
+ error.item = current
290
+ throw error
291
+ }
292
+
293
+ const notePath = path.resolve(current.notePath)
294
+ const resultDir = path.resolve(current.resultDir)
295
+ if (!isInside(notePath, resultDir)) {
296
+ const error = new Error("Note path is outside the note folder")
297
+ error.code = "NOTE_INVALID_PATH"
298
+ throw error
299
+ }
300
+
301
+ await fs.promises.writeFile(notePath, markdown, "utf8")
302
+ return refreshPendingItem(current)
303
+ }
304
+
305
+ async function start() {
306
+ }
307
+
308
+ async function stop() {
309
+ }
310
+
311
+ return {
312
+ RESULT_RELATIVE_DIR,
313
+ getPendingById,
314
+ inspectNoteBundle,
315
+ inspectWorkspace,
316
+ listPending,
317
+ savePendingById,
318
+ start,
319
+ stop,
320
+ trackWorkspace
321
+ }
322
+ }
323
+
324
+ module.exports = {
325
+ createNoteService
326
+ }
@@ -5,21 +5,19 @@ function isInside(candidate, parent) {
5
5
  return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
6
6
  }
7
7
 
8
- class DraftWatcher {
8
+ class NoteWatcher {
9
9
  constructor(options = {}) {
10
- this.drafts = options.drafts
10
+ this.notes = options.notes
11
11
  }
12
12
 
13
- async ready(ctx, params = {}) {
14
- if (!this.drafts || typeof this.drafts.inspectWorkspace !== "function") {
15
- throw new Error("draft service is unavailable")
13
+ async watch(ctx, params = {}) {
14
+ if (!this.notes || typeof this.notes.inspectWorkspace !== "function") {
15
+ throw new Error("note service is unavailable")
16
16
  }
17
17
 
18
- const draftDir = ctx.resolve(params.path || ".pinokio/draft")
19
- const draftConfig = {
20
- path: params.path || ".pinokio/draft",
21
- content: params.content,
22
- ready: params.ready,
18
+ const noteDir = ctx.resolve(params.path || ".pinokio/notes")
19
+ const noteConfig = {
20
+ path: params.path || ".pinokio/notes",
23
21
  description: params.description,
24
22
  publish: params.publish
25
23
  }
@@ -28,7 +26,7 @@ class DraftWatcher {
28
26
 
29
27
  const inspect = async () => {
30
28
  if (disposed) return
31
- await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig })
29
+ await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig })
32
30
  }
33
31
 
34
32
  const scheduleInspect = () => {
@@ -37,7 +35,7 @@ class DraftWatcher {
37
35
  timer = setTimeout(() => {
38
36
  timer = null
39
37
  inspect().catch((error) => {
40
- console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error)
38
+ console.warn("[notes] failed to inspect workspace", error && error.message ? error.message : error)
41
39
  })
42
40
  }, 250)
43
41
  }
@@ -46,12 +44,12 @@ class DraftWatcher {
46
44
  const stopPoll = ctx.poll(params.interval || 1500, inspect, {
47
45
  immediate: false,
48
46
  onError: (error) => {
49
- console.warn("[drafts] poll failed", error && error.message ? error.message : error)
47
+ console.warn("[notes] poll failed", error && error.message ? error.message : error)
50
48
  }
51
49
  })
52
50
  const unsubscribe = await ctx.watch.fs(ctx.cwd, (events) => {
53
51
  if (!Array.isArray(events)) return
54
- if (events.some((event) => event && event.path && isInside(path.resolve(event.path), draftDir))) {
52
+ if (events.some((event) => event && event.path && isInside(path.resolve(event.path), noteDir))) {
55
53
  scheduleInspect()
56
54
  }
57
55
  })
@@ -61,7 +59,7 @@ class DraftWatcher {
61
59
  clearTimeout(timer)
62
60
  timer = null
63
61
  }
64
- await this.drafts.inspectWorkspace({ cwd: ctx.cwd, draft: draftConfig }).catch(() => {})
62
+ await this.notes.inspectWorkspace({ cwd: ctx.cwd, note: noteConfig }).catch(() => {})
65
63
  disposed = true
66
64
  if (typeof stopPoll === "function") {
67
65
  await stopPoll()
@@ -73,4 +71,4 @@ class DraftWatcher {
73
71
  }
74
72
  }
75
73
 
76
- module.exports = DraftWatcher
74
+ module.exports = NoteWatcher