@soleone/pi-tasks 0.2.1

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.
@@ -0,0 +1,446 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { createHash, randomUUID } from "node:crypto"
3
+ import { existsSync } from "node:fs"
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
5
+ import { dirname, resolve } from "node:path"
6
+ import type { Task, TaskStatus } from "../../models/task.ts"
7
+ import type { CreateTaskInput, TaskAdapter, TaskAdapterInitializer, TaskStatusMap, TaskUpdate } from "../api.ts"
8
+
9
+ const DEFAULT_TODO_FILES = ["TODO.md", "todo.md"] as const
10
+ const TODO_FILE_ENV = "PI_TASKS_TODO_PATH"
11
+ const SECTION_ORDER = ["now", "next", "later", "archive"] as const
12
+ const OPEN_PRIORITIES = ["now", "next", "later"] as const
13
+ const DEFAULT_TODO_TITLE = "TODO"
14
+
15
+ const STATUS_MAP = {
16
+ open: "open",
17
+ closed: "done",
18
+ } satisfies TaskStatusMap
19
+
20
+ type TodoSection = typeof SECTION_ORDER[number]
21
+ type TodoPriority = typeof OPEN_PRIORITIES[number]
22
+
23
+ interface TodoTaskRecord {
24
+ ref: string
25
+ title: string
26
+ description: string
27
+ status: TaskStatus
28
+ priority: TodoPriority | undefined
29
+ }
30
+
31
+ interface TodoDocument {
32
+ title: string
33
+ format: "structured" | "flat"
34
+ tasks: TodoTaskRecord[]
35
+ }
36
+
37
+ function configuredTodoPath(): string {
38
+ const fromEnv = process.env[TODO_FILE_ENV]?.trim()
39
+ if (fromEnv && fromEnv.length > 0) return fromEnv
40
+
41
+ const existingDefault = DEFAULT_TODO_FILES.find(file => existsSync(resolve(process.cwd(), file)))
42
+ return existingDefault ?? DEFAULT_TODO_FILES[0]
43
+ }
44
+
45
+ function resolveTodoPath(): string {
46
+ return resolve(process.cwd(), configuredTodoPath())
47
+ }
48
+
49
+ function sectionToPriority(section: TodoSection): TodoPriority | undefined {
50
+ if (section === "archive") return undefined
51
+ return section
52
+ }
53
+
54
+ function headingToSection(line: string): TodoSection | null {
55
+ const match = line.match(/^##\s+(now|next|later|archive)\s*$/i)
56
+ if (!match) return null
57
+ return match[1]!.toLowerCase() as TodoSection
58
+ }
59
+
60
+ function parseTaskLine(line: string): { checked: boolean; title: string; inlineDescription: string } | null {
61
+ const match = line.match(/^\s*-\s*\[( |x|X)\]\s*(?:\*\*(.+?)\*\*|(.+?))(?:\s*[—-]\s*(.+))?\s*$/)
62
+ if (!match) return null
63
+
64
+ return {
65
+ checked: match[1]!.toLowerCase() === "x",
66
+ title: (match[2] ?? match[3] ?? "").trim(),
67
+ inlineDescription: (match[4] ?? "").trim(),
68
+ }
69
+ }
70
+
71
+ function parseDescriptionBullet(line: string): string | null {
72
+ const match = line.match(/^\s{2,}-\s+(.+)$/)
73
+ if (!match) return null
74
+ return `- ${match[1]!.trim()}`
75
+ }
76
+
77
+ function computeTaskRef(title: string, description: string, occurrence: number): string {
78
+ const digest = createHash("sha1")
79
+ .update(title.trim())
80
+ .update("\n")
81
+ .update(description.trim())
82
+ .digest("hex")
83
+ .slice(0, 10)
84
+
85
+ return `todo-${digest}-${occurrence}`
86
+ }
87
+
88
+ function createNewTaskRef(existingRefs: Set<string>): string {
89
+ let candidate = `todo-${randomUUID().slice(0, 8)}`
90
+ while (existingRefs.has(candidate)) {
91
+ candidate = `todo-${randomUUID().slice(0, 8)}`
92
+ }
93
+ return candidate
94
+ }
95
+
96
+ function assignTaskRefs(tasks: Omit<TodoTaskRecord, "ref">[]): TodoTaskRecord[] {
97
+ const seen = new Map<string, number>()
98
+
99
+ return tasks.map((task) => {
100
+ const key = `${task.title.trim()}\n${task.description.trim()}`
101
+ const nextOccurrence = (seen.get(key) ?? 0) + 1
102
+ seen.set(key, nextOccurrence)
103
+
104
+ return {
105
+ ...task,
106
+ ref: computeTaskRef(task.title, task.description, nextOccurrence),
107
+ }
108
+ })
109
+ }
110
+
111
+ function extractTitle(lines: string[]): string {
112
+ const heading = lines.find(line => /^#\s+/.test(line.trim()))
113
+ if (!heading) return DEFAULT_TODO_TITLE
114
+ return heading.replace(/^#\s+/, "").trim() || DEFAULT_TODO_TITLE
115
+ }
116
+
117
+ function parseChecklistTasks(
118
+ lines: string[],
119
+ resolvePriority: (section: TodoSection | null) => TodoPriority | undefined,
120
+ ): Omit<TodoTaskRecord, "ref">[] {
121
+ const parsedTasks: Omit<TodoTaskRecord, "ref">[] = []
122
+
123
+ let section: TodoSection | null = null
124
+ let activeTask: {
125
+ checked: boolean
126
+ title: string
127
+ inlineDescription: string
128
+ bullets: string[]
129
+ section: TodoSection | null
130
+ } | null = null
131
+
132
+ const flushActiveTask = () => {
133
+ if (!activeTask) return
134
+
135
+ const description = activeTask.bullets.length > 0
136
+ ? activeTask.bullets.join("\n")
137
+ : activeTask.inlineDescription
138
+
139
+ parsedTasks.push({
140
+ title: activeTask.title,
141
+ description,
142
+ status: activeTask.checked ? "closed" : "open",
143
+ priority: activeTask.checked ? undefined : resolvePriority(activeTask.section),
144
+ })
145
+
146
+ activeTask = null
147
+ }
148
+
149
+ for (const line of lines) {
150
+ const nextSection = headingToSection(line)
151
+ if (nextSection) {
152
+ flushActiveTask()
153
+ section = nextSection
154
+ continue
155
+ }
156
+
157
+ const taskLine = parseTaskLine(line)
158
+ if (taskLine) {
159
+ flushActiveTask()
160
+ activeTask = {
161
+ ...taskLine,
162
+ bullets: [],
163
+ section,
164
+ }
165
+ continue
166
+ }
167
+
168
+ if (!activeTask) continue
169
+
170
+ const bullet = parseDescriptionBullet(line)
171
+ if (bullet) {
172
+ activeTask.bullets.push(bullet)
173
+ continue
174
+ }
175
+
176
+ if (line.trim().length === 0) continue
177
+
178
+ flushActiveTask()
179
+ }
180
+
181
+ flushActiveTask()
182
+
183
+ return parsedTasks
184
+ }
185
+
186
+ function parseTodoDocument(content: string): TodoDocument {
187
+ const lines = content.split(/\r?\n/)
188
+ const title = extractTitle(lines)
189
+ const hasStructuredSections = lines.some(line => headingToSection(line) !== null)
190
+
191
+ if (hasStructuredSections) {
192
+ const tasks = parseChecklistTasks(lines, section => sectionToPriority(section ?? "now") ?? "now")
193
+ return {
194
+ title,
195
+ format: "structured",
196
+ tasks: assignTaskRefs(tasks),
197
+ }
198
+ }
199
+
200
+ const tasks = parseChecklistTasks(lines, () => "now")
201
+ return {
202
+ title,
203
+ format: "flat",
204
+ tasks: assignTaskRefs(tasks),
205
+ }
206
+ }
207
+
208
+ function asBulletLines(description: string): string[] {
209
+ const normalized = description
210
+ .split(/\r?\n/)
211
+ .map(line => line.trim())
212
+ .filter(line => line.length > 0)
213
+
214
+ if (normalized.length === 0) return []
215
+
216
+ return normalized.map(line => line.startsWith("- ") ? line : `- ${line}`)
217
+ }
218
+
219
+ function taskToMarkdownLine(task: TodoTaskRecord): string[] {
220
+ const checked = task.status === "closed" ? "x" : " "
221
+ const title = task.title.trim()
222
+ const description = task.description.trim()
223
+
224
+ if (description.length === 0) {
225
+ return [`- [${checked}] **${title}**`]
226
+ }
227
+
228
+ const descriptionLines = description.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
229
+ if (descriptionLines.length === 1 && !descriptionLines[0]!.startsWith("- ")) {
230
+ return [`- [${checked}] **${title}** — ${descriptionLines[0]}`]
231
+ }
232
+
233
+ const lines = [`- [${checked}] **${title}**`]
234
+ for (const bullet of asBulletLines(description)) {
235
+ lines.push(` ${bullet}`)
236
+ }
237
+ return lines
238
+ }
239
+
240
+ function renderStructuredDocument(document: TodoDocument): string {
241
+ const sectionTasks: Record<TodoSection, TodoTaskRecord[]> = {
242
+ now: [],
243
+ next: [],
244
+ later: [],
245
+ archive: [],
246
+ }
247
+
248
+ for (const task of document.tasks) {
249
+ if (task.status === "closed") {
250
+ sectionTasks.archive.push(task)
251
+ continue
252
+ }
253
+
254
+ const priority = task.priority ?? "now"
255
+ sectionTasks[priority].push(task)
256
+ }
257
+
258
+ const lines: string[] = [`# ${document.title}`, ""]
259
+
260
+ const sectionTitleById: Record<TodoSection, string> = {
261
+ now: "Now",
262
+ next: "Next",
263
+ later: "Later",
264
+ archive: "Archive",
265
+ }
266
+
267
+ for (const section of SECTION_ORDER) {
268
+ lines.push(`## ${sectionTitleById[section]}`)
269
+ lines.push("")
270
+
271
+ for (const task of sectionTasks[section]) {
272
+ lines.push(...taskToMarkdownLine(task))
273
+ }
274
+
275
+ lines.push("")
276
+ }
277
+
278
+ return `${lines.join("\n").trimEnd()}\n`
279
+ }
280
+
281
+ function renderFlatDocument(document: TodoDocument): string {
282
+ const lines: string[] = [`# ${document.title}`, ""]
283
+
284
+ const openTasks = document.tasks.filter(task => task.status === "open")
285
+ for (const task of openTasks) {
286
+ lines.push(...taskToMarkdownLine(task))
287
+ }
288
+
289
+ const archivedTasks = document.tasks.filter(task => task.status === "closed")
290
+ if (archivedTasks.length > 0) {
291
+ lines.push("", "## Archive", "")
292
+ for (const task of archivedTasks) {
293
+ lines.push(...taskToMarkdownLine(task))
294
+ }
295
+ }
296
+
297
+ lines.push("")
298
+ return `${lines.join("\n").trimEnd()}\n`
299
+ }
300
+
301
+ function renderTodoDocument(document: TodoDocument): string {
302
+ const shouldUseStructured = document.format === "structured" || document.tasks.some(task => (
303
+ task.status === "open" && task.priority !== undefined && task.priority !== "now"
304
+ ))
305
+
306
+ return shouldUseStructured
307
+ ? renderStructuredDocument(document)
308
+ : renderFlatDocument(document)
309
+ }
310
+
311
+ function normalizePriority(priority: string | undefined): TodoPriority | undefined {
312
+ if (!priority) return undefined
313
+ const normalized = priority.toLowerCase()
314
+ if (normalized === "now" || normalized === "next" || normalized === "later") return normalized
315
+ return undefined
316
+ }
317
+
318
+ function toTask(task: TodoTaskRecord): Task {
319
+ return {
320
+ ref: task.ref,
321
+ title: task.title,
322
+ description: task.description,
323
+ status: task.status,
324
+ priority: task.priority,
325
+ taskType: "task",
326
+ }
327
+ }
328
+
329
+ function normalizeStatus(status: TaskStatus | undefined): TaskStatus {
330
+ if (status === "closed") return "closed"
331
+ return "open"
332
+ }
333
+
334
+ function applyTaskUpdate(task: TodoTaskRecord, update: TaskUpdate): TodoTaskRecord {
335
+ const nextTitle = update.title !== undefined ? update.title.trim() : task.title
336
+ const nextDescription = update.description !== undefined ? update.description : task.description
337
+ const nextStatus = update.status !== undefined ? normalizeStatus(update.status) : task.status
338
+ const nextPriority = update.priority !== undefined
339
+ ? normalizePriority(update.priority) ?? task.priority
340
+ : task.priority
341
+
342
+ return {
343
+ ...task,
344
+ title: nextTitle,
345
+ description: nextDescription,
346
+ status: nextStatus,
347
+ priority: nextStatus === "closed" ? undefined : (nextPriority ?? "now"),
348
+ }
349
+ }
350
+
351
+ async function readDocument(filePath: string): Promise<TodoDocument> {
352
+ try {
353
+ const content = await readFile(filePath, "utf8")
354
+ return parseTodoDocument(content)
355
+ } catch (error) {
356
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
357
+ return { title: DEFAULT_TODO_TITLE, format: "structured", tasks: [] }
358
+ }
359
+ throw error
360
+ }
361
+ }
362
+
363
+ async function writeDocument(filePath: string, document: TodoDocument): Promise<void> {
364
+ await mkdir(dirname(filePath), { recursive: true })
365
+ await writeFile(filePath, renderTodoDocument(document), "utf8")
366
+ }
367
+
368
+ function isApplicable(): boolean {
369
+ return existsSync(resolveTodoPath())
370
+ }
371
+
372
+ function initialize(_pi: ExtensionAPI): TaskAdapter {
373
+ const filePath = resolveTodoPath()
374
+ let documentCache: TodoDocument | null = null
375
+
376
+ async function getDocument(): Promise<TodoDocument> {
377
+ if (documentCache) return documentCache
378
+ documentCache = await readDocument(filePath)
379
+ return documentCache
380
+ }
381
+
382
+ async function persistDocument(document: TodoDocument): Promise<void> {
383
+ documentCache = document
384
+ await writeDocument(filePath, document)
385
+ }
386
+
387
+ return {
388
+ id: "todo-md",
389
+ statusMap: STATUS_MAP,
390
+ taskTypes: ["task"],
391
+ priorities: [...OPEN_PRIORITIES],
392
+ invalidateCache: () => {
393
+ documentCache = null
394
+ },
395
+
396
+ async list(): Promise<Task[]> {
397
+ const document = await getDocument()
398
+ return document.tasks
399
+ .filter(task => task.status === "open")
400
+ .map(toTask)
401
+ },
402
+
403
+ async show(ref: string): Promise<Task> {
404
+ const document = await getDocument()
405
+ const task = document.tasks.find(item => item.ref === ref)
406
+ if (!task) throw new Error(`Task not found: ${ref}`)
407
+ return toTask(task)
408
+ },
409
+
410
+ async update(ref: string, update: TaskUpdate): Promise<void> {
411
+ const document = await getDocument()
412
+ const index = document.tasks.findIndex(task => task.ref === ref)
413
+ if (index === -1) throw new Error(`Task not found: ${ref}`)
414
+
415
+ const updatedTasks = [...document.tasks]
416
+ updatedTasks[index] = applyTaskUpdate(updatedTasks[index]!, update)
417
+
418
+ await persistDocument({ ...document, tasks: updatedTasks })
419
+ },
420
+
421
+ async create(input: CreateTaskInput): Promise<Task> {
422
+ const document = await getDocument()
423
+
424
+ const status = normalizeStatus(input.status)
425
+ const existingRefs = new Set(document.tasks.map(task => task.ref))
426
+ const createdTask: TodoTaskRecord = {
427
+ ref: createNewTaskRef(existingRefs),
428
+ title: input.title.trim(),
429
+ description: input.description ?? "",
430
+ status,
431
+ priority: status === "closed"
432
+ ? undefined
433
+ : (normalizePriority(input.priority) ?? "now"),
434
+ }
435
+
436
+ await persistDocument({ ...document, tasks: [...document.tasks, createdTask] })
437
+ return toTask(createdTask)
438
+ },
439
+ }
440
+ }
441
+
442
+ export default {
443
+ id: "todo-md",
444
+ isApplicable,
445
+ initialize,
446
+ } satisfies TaskAdapterInitializer
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import type { Task, TaskStatus } from "../models/task.ts"
3
+
4
+ export type TaskStatusMap = {
5
+ open: string
6
+ closed: string
7
+ inProgress?: string
8
+ } & Partial<Record<Exclude<TaskStatus, "open" | "inProgress" | "closed">, string>>
9
+
10
+ export interface TaskUpdate {
11
+ title?: string
12
+ description?: string
13
+ status?: TaskStatus
14
+ priority?: string
15
+ taskType?: string
16
+ dueAt?: string
17
+ }
18
+
19
+ export interface CreateTaskInput extends TaskUpdate {
20
+ title: string
21
+ }
22
+
23
+ export interface TaskAdapter {
24
+ readonly id: string
25
+ readonly statusMap: TaskStatusMap
26
+ readonly taskTypes: string[]
27
+ readonly priorities: string[]
28
+ readonly priorityHotkeys?: Record<string, string>
29
+ invalidateCache?(): void
30
+ list(): Promise<Task[]>
31
+ show(ref: string): Promise<Task>
32
+ update(ref: string, update: TaskUpdate): Promise<void>
33
+ create(input: CreateTaskInput): Promise<Task>
34
+ }
35
+
36
+ export interface TaskAdapterInitializer {
37
+ readonly id: string
38
+ isApplicable(): boolean
39
+ initialize(pi: ExtensionAPI): TaskAdapter
40
+ }
@@ -0,0 +1,86 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { readdir } from "node:fs/promises"
3
+ import { dirname, extname, join } from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+ import type { TaskAdapter, TaskAdapterInitializer } from "./api.ts"
6
+
7
+ const ADAPTERS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "adapters")
8
+ const SUPPORTED_ADAPTER_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".cjs"])
9
+
10
+ function isTaskAdapterInitializer(value: unknown): value is TaskAdapterInitializer {
11
+ if (!value || typeof value !== "object") return false
12
+
13
+ const candidate = value as {
14
+ id?: unknown
15
+ isApplicable?: unknown
16
+ initialize?: unknown
17
+ }
18
+
19
+ return (
20
+ typeof candidate.id === "string" &&
21
+ typeof candidate.isApplicable === "function" &&
22
+ typeof candidate.initialize === "function"
23
+ )
24
+ }
25
+
26
+ async function loadAdapterInitializers(): Promise<TaskAdapterInitializer[]> {
27
+ const entries = await readdir(ADAPTERS_DIRECTORY, { withFileTypes: true })
28
+ const adapterFiles = entries
29
+ .filter(entry => entry.isFile())
30
+ .map(entry => entry.name)
31
+ .filter(name => SUPPORTED_ADAPTER_EXTENSIONS.has(extname(name)))
32
+ .sort((a, b) => a.localeCompare(b))
33
+
34
+ const adapters: TaskAdapterInitializer[] = []
35
+
36
+ for (const fileName of adapterFiles) {
37
+ const modulePath = pathToFileURL(join(ADAPTERS_DIRECTORY, fileName)).href
38
+ const module = await import(modulePath)
39
+ const initializer = module.default
40
+
41
+ if (!isTaskAdapterInitializer(initializer)) {
42
+ throw new Error(`Invalid adapter export in ${fileName}: expected default TaskAdapterInitializer`)
43
+ }
44
+
45
+ adapters.push(initializer)
46
+ }
47
+
48
+ if (adapters.length === 0) {
49
+ throw new Error(`No task adapters found in ${ADAPTERS_DIRECTORY}`)
50
+ }
51
+
52
+ const ids = new Set<string>()
53
+ for (const adapter of adapters) {
54
+ if (ids.has(adapter.id)) {
55
+ throw new Error(`Duplicate task adapter id detected: ${adapter.id}`)
56
+ }
57
+ ids.add(adapter.id)
58
+ }
59
+
60
+ return adapters
61
+ }
62
+
63
+ const ADAPTER_INITIALIZERS = await loadAdapterInitializers()
64
+
65
+ function lookup(): TaskAdapterInitializer {
66
+ const configuredAdapterId = process.env.PI_TASKS_BACKEND?.trim()
67
+ if (configuredAdapterId) {
68
+ const configured = ADAPTER_INITIALIZERS.find(adapter => adapter.id === configuredAdapterId)
69
+ if (!configured) {
70
+ throw new Error(`Unsupported tasks backend: ${configuredAdapterId}`)
71
+ }
72
+ return configured
73
+ }
74
+
75
+ const detected = ADAPTER_INITIALIZERS.find(adapter => adapter.isApplicable())
76
+ if (detected) return detected
77
+
78
+ const fallback = ADAPTER_INITIALIZERS.find(adapter => adapter.id === "todo-md")
79
+ if (fallback) return fallback
80
+
81
+ return ADAPTER_INITIALIZERS[0]
82
+ }
83
+
84
+ export default function initializeAdapter(pi: ExtensionAPI): TaskAdapter {
85
+ return lookup().initialize(pi)
86
+ }