opencode-global-habit-profile 0.0.0-stage → 0.2.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.
@@ -0,0 +1,215 @@
1
+ import { statSync } from "node:fs"
2
+
3
+ const hookNames = Object.freeze(["session.prompt", "session.context", "tool.execute.after", "shell.create.before"])
4
+ const latencyBuckets = Object.freeze([1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000])
5
+ const histogram = () => ({ counts: new Array(latencyBuckets.length).fill(0) })
6
+ let startedAt
7
+ let hooks
8
+ let context
9
+ let store
10
+ let sse
11
+ let requests
12
+ let flush
13
+ let errors
14
+
15
+ function reset() {
16
+ startedAt = Date.now()
17
+ hooks = Object.fromEntries(hookNames.map((name) => [name, { invocations: 0, errors: 0, latency: histogram() }]))
18
+ context = { injections: 0, injectedChars: 0, notices: { checkpoint: 0, todo: 0 } }
19
+ store = { reads: 0, writes: 0, bytesRead: 0, bytesWritten: 0, lockWaits: 0, lockTimeouts: 0, errors: 0, writeLatency: histogram() }
20
+ sse = { clients: 0, peakClients: 0, refreshes: 0 }
21
+ requests = { "2xx": 0, "4xx": 0, "5xx": 0 }
22
+ flush = { lastAt: null, pending: 0, failures: 0 }
23
+ errors = { hooks: 0, store: 0, server: 0, collector: 0 }
24
+ }
25
+ reset()
26
+
27
+ function observe(histogramValue, durationMs) {
28
+ const value = Number(durationMs)
29
+ if (!Number.isFinite(value) || value < 0) return
30
+ let index = latencyBuckets.findIndex((max) => value <= max)
31
+ if (index === -1) index = latencyBuckets.length - 1
32
+ histogramValue.counts[index]++
33
+ }
34
+
35
+ function percentile(histogramValue, quantile) {
36
+ const samples = histogramValue.counts.reduce((sum, count) => sum + count, 0)
37
+ if (!samples) return null
38
+ const target = Math.ceil(samples * quantile)
39
+ let seen = 0
40
+ for (let index = 0; index < histogramValue.counts.length; index++) {
41
+ seen += histogramValue.counts[index]
42
+ if (seen >= target) return latencyBuckets[index]
43
+ }
44
+ return latencyBuckets.at(-1)
45
+ }
46
+
47
+ function histogramSnapshot(histogramValue) {
48
+ return {
49
+ samples: histogramValue.counts.reduce((sum, count) => sum + count, 0),
50
+ p50: percentile(histogramValue, 0.5),
51
+ p95: percentile(histogramValue, 0.95),
52
+ buckets: histogramValue.counts.map((count, index) => ({ maxMs: index === histogramValue.counts.length - 1 ? null : latencyBuckets[index], count })),
53
+ }
54
+ }
55
+
56
+ function recordHook(name, durationMs, failed) {
57
+ const hook = hooks[name]
58
+ if (!hook) return
59
+ hook.invocations++
60
+ observe(hook.latency, durationMs)
61
+ if (failed) {
62
+ hook.errors++
63
+ errors.hooks++
64
+ }
65
+ }
66
+
67
+ export async function measureHook(name, fn) {
68
+ const started = Date.now()
69
+ try {
70
+ const result = await fn()
71
+ recordHook(name, Date.now() - started, false)
72
+ return result
73
+ } catch (error) {
74
+ recordHook(name, Date.now() - started, true)
75
+ throw error
76
+ }
77
+ }
78
+
79
+ export function recordHookError(name) {
80
+ const hook = hooks[name]
81
+ if (!hook) return
82
+ hook.errors++
83
+ errors.hooks++
84
+ }
85
+
86
+ export function recordContextChars(chars) {
87
+ const value = Number(chars)
88
+ if (!Number.isSafeInteger(value) || value < 0) return
89
+ if (!value) return
90
+ context.injections++
91
+ context.injectedChars += value
92
+ }
93
+
94
+ export function recordNotice(kind) {
95
+ if (kind === "checkpoint" || kind === "todo") context.notices[kind]++
96
+ }
97
+
98
+ export function recordStoreRead(bytes) {
99
+ store.reads++
100
+ const value = Number(bytes)
101
+ if (Number.isSafeInteger(value) && value >= 0) store.bytesRead += value
102
+ }
103
+
104
+ export function recordStoreWrite(bytes, durationMs) {
105
+ store.writes++
106
+ const value = Number(bytes)
107
+ if (Number.isSafeInteger(value) && value >= 0) store.bytesWritten += value
108
+ observe(store.writeLatency, durationMs)
109
+ }
110
+
111
+ export function recordStoreLockWait() {
112
+ store.lockWaits++
113
+ }
114
+
115
+ export function recordStoreLockTimeout() {
116
+ store.lockTimeouts++
117
+ errors.store++
118
+ }
119
+
120
+ export function recordStoreError() {
121
+ store.errors++
122
+ errors.store++
123
+ }
124
+
125
+ export function recordSseClient(delta) {
126
+ sse.clients = Math.max(0, sse.clients + (Number(delta) || 0))
127
+ sse.peakClients = Math.max(sse.peakClients, sse.clients)
128
+ }
129
+
130
+ export function recordSseRefresh() {
131
+ sse.refreshes++
132
+ }
133
+
134
+ export function recordRequestStatus(status) {
135
+ const code = Number(status)
136
+ if (code >= 200 && code < 300) requests["2xx"]++
137
+ else if (code >= 400 && code < 500) requests["4xx"]++
138
+ else if (code >= 500 && code < 600) {
139
+ requests["5xx"]++
140
+ errors.server++
141
+ }
142
+ }
143
+
144
+ export function recordServerError() {
145
+ errors.server++
146
+ }
147
+
148
+ export function recordCollectorPending(pending) {
149
+ const value = Number(pending)
150
+ flush.pending = Number.isSafeInteger(value) && value >= 0 ? value : 0
151
+ }
152
+
153
+ export function recordCollectorFlush(pending, failed) {
154
+ flush.lastAt = Date.now()
155
+ recordCollectorPending(pending)
156
+ if (failed) {
157
+ flush.failures++
158
+ errors.collector++
159
+ }
160
+ }
161
+
162
+ function currentCounts(storeData) {
163
+ if (!storeData || typeof storeData !== "object") return null
164
+ const checkpoints = Array.isArray(storeData.checkpoints) ? storeData.checkpoints : []
165
+ const todos = Array.isArray(storeData.todos) ? storeData.todos : []
166
+ return {
167
+ checkpoints: checkpoints.length,
168
+ todos: todos.length,
169
+ activeTodos: todos.filter((todo) => todo?.status === "pending").length,
170
+ scopes: new Set(checkpoints.map((checkpoint) => `${checkpoint?.project}\u0000${checkpoint?.context}`)).size,
171
+ }
172
+ }
173
+
174
+ function fileSize(path) {
175
+ try { return statSync(path).size } catch { return null }
176
+ }
177
+
178
+ export function storeStats() {
179
+ return {
180
+ reads: store.reads,
181
+ writes: store.writes,
182
+ bytesRead: store.bytesRead,
183
+ bytesWritten: store.bytesWritten,
184
+ lockWaits: store.lockWaits,
185
+ lockTimeouts: store.lockTimeouts,
186
+ errors: store.errors,
187
+ writeLatency: histogramSnapshot(store.writeLatency),
188
+ }
189
+ }
190
+
191
+ export function pluginSnapshot({ storeData, storePath, metricsPath } = {}) {
192
+ return {
193
+ startedAt: new Date(startedAt).toISOString(),
194
+ uptimeMs: Math.max(0, Date.now() - startedAt),
195
+ hooks: Object.fromEntries(Object.entries(hooks).map(([name, hook]) => [name, {
196
+ invocations: hook.invocations,
197
+ errors: hook.errors,
198
+ latency: histogramSnapshot(hook.latency),
199
+ }])),
200
+ context: { injections: context.injections, injectedChars: context.injectedChars, notices: { ...context.notices } },
201
+ store: storeStats(),
202
+ sse: { clients: sse.clients, peakClients: sse.peakClients, refreshes: sse.refreshes },
203
+ requests: { ...requests },
204
+ flush: { ...flush },
205
+ errors: { ...errors },
206
+ current: currentCounts(storeData),
207
+ bytes: { store: fileSize(storePath), metrics: fileSize(metricsPath) },
208
+ }
209
+ }
210
+
211
+ export function resetPluginStats() {
212
+ reset()
213
+ }
214
+
215
+ export { hookNames }
package/src/store.js ADDED
@@ -0,0 +1,296 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { homedir } from "node:os"
5
+ import { recordStoreError, recordStoreLockTimeout, recordStoreLockWait, recordStoreRead, recordStoreWrite, storeStats } from "./plugin-stats.js"
6
+
7
+ const maxBytes = 2_000_000
8
+ const categories = new Set([
9
+ ...["bash", "read", "write", "edit", "patch", "glob", "grep", "task", "subagent", "websearch", "webfetch"].flatMap((n) => [`tool:${n}`, `request:tool:${n}`]),
10
+ ...["git", "php", "python", "python3", "pytest", "composer", "npm", "pnpm", "yarn", "bun", "docker", "opencode", "codesearch"].flatMap((n) => [`cli:${n}`, `request:cli:${n}`]),
11
+ ...["build", "plan", "code", "explore", "general", "orquestador", "01-debug", "02-agente-plan", "03-fix", "04-test", "agente-debugger", "cambio-quirurgico", "ciberseguridad", "django-expert", "docker", "docker-expert", "frontend-designer", "git", "investigador-cientifico", "investigador-secundario", "linux-expert", "minecraft", "versionador"].map((n) => `request:agent:${n}`),
12
+ ])
13
+ const root = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
14
+ export const storePath = join(root, "habit-memory-plugin", "store-v1.json")
15
+ const lockPath = `${storePath}.lock`
16
+ const recoveryPath = `${lockPath}.recovery`
17
+ const staleLockMs = 120_000
18
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino
19
+ async function unlinkIfSame(path, identity) {
20
+ try { if (sameFile(identity, await stat(path))) await unlink(path) } catch {}
21
+ }
22
+ async function hasRecoveryClaim() {
23
+ try { await stat(recoveryPath); return true } catch (error) {
24
+ if (error.code === "ENOENT") return false
25
+ throw error
26
+ }
27
+ }
28
+ async function lockSnapshot() { return { stat: await stat(lockPath), contents: await readFile(lockPath, "utf8") } }
29
+ function sameLock(a, b) {
30
+ return sameFile(a.stat, b.stat) && a.stat.size === b.stat.size && a.stat.mtimeMs === b.stat.mtimeMs && a.stat.ctimeMs === b.stat.ctimeMs && a.contents === b.contents
31
+ }
32
+ async function recoverStaleLock() {
33
+ let initial
34
+ try {
35
+ initial = await lockSnapshot()
36
+ if (Date.now() - initial.stat.mtimeMs <= staleLockMs) return
37
+ } catch { return }
38
+ const pid = Number(initial.contents), validPid = Number.isSafeInteger(pid) && pid > 0
39
+ if (validPid) {
40
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
41
+ }
42
+ let claim
43
+ try { claim = await open(recoveryPath, "wx", 0o600) } catch { return }
44
+ try {
45
+ const before = await lockSnapshot()
46
+ if (!sameLock(initial, before) || Date.now() - before.stat.mtimeMs <= staleLockMs) return
47
+ if (validPid) {
48
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
49
+ }
50
+ const current = await lockSnapshot()
51
+ if (!sameLock(before, current) || Date.now() - current.stat.mtimeMs <= staleLockMs) return
52
+ if (validPid) {
53
+ try { process.kill(pid, 0); return } catch (error) { if (error.code !== "ESRCH") return }
54
+ }
55
+ await unlink(lockPath)
56
+ } catch {
57
+ // Any uncertainty leaves the existing lock in place.
58
+ } finally {
59
+ await claim.close().catch(() => {})
60
+ await unlink(recoveryPath).catch(() => {})
61
+ }
62
+ }
63
+
64
+ const empty = () => ({ version: 1, habits: { version: 1, counts: {}, daily: {} }, checkpoints: [], todos: [] })
65
+ const text = (v, max = 240) => typeof v === "string" && v.length > 0 && v.length <= max
66
+ const basenameValue = (v) => typeof v === "string" && v.length > 0 && v.length <= 128 && v === v.trim() && v !== "." && v !== ".." && !/[\\/\u0000-\u001f\u007f]/.test(v) ? v : undefined
67
+ const validDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
68
+ function valid(data) {
69
+ if (!data || Object.keys(data).some((k) => !["version", "habits", "checkpoints", "todos"].includes(k)) || data.version !== 1 || !data.habits || Object.keys(data.habits).some((k) => !["version", "counts", "daily", "legacyImported"].includes(k)) || data.habits.version !== 1 || !data.habits.counts || (data.habits.legacyImported !== undefined && typeof data.habits.legacyImported !== "boolean") || !Array.isArray(data.checkpoints) || (data.todos !== undefined && !Array.isArray(data.todos))) throw new Error("Invalid habit-memory store schema")
70
+ for (const [key, count] of Object.entries(data.habits.counts)) if (!categories.has(key) || !Number.isSafeInteger(count) || count < 1 || count > 999) throw new Error("Invalid habits data")
71
+ const daily = data.habits.daily === undefined ? {} : data.habits.daily
72
+ if (!daily || typeof daily !== "object" || Array.isArray(daily) || Object.keys(daily).length > 365) throw new Error("Invalid daily habit data")
73
+ for (const [date, counts] of Object.entries(daily)) {
74
+ if (!validDate(date) || !counts || typeof counts !== "object" || Array.isArray(counts) || !Object.keys(counts).length) throw new Error("Invalid daily habit data")
75
+ for (const [key, count] of Object.entries(counts)) if (!categories.has(key) || !Number.isSafeInteger(count) || count < 1) throw new Error("Invalid daily habit data")
76
+ }
77
+ data.habits.daily = daily
78
+ if (data.checkpoints.length > 10000 || data.checkpoints.some((c) => !c || Object.keys(c).some((k) => !["id", "project", "context", "summary", "createdAt", "sessionTitle", "projectDirectoryName"].includes(k)) || !text(c.id, 80) || !text(c.project, 128) || !text(c.context, 128) || !text(c.summary) || !text(c.createdAt, 40) || (c.sessionTitle !== undefined && !text(c.sessionTitle, 200)) || (c.projectDirectoryName !== undefined && !basenameValue(c.projectDirectoryName)))) throw new Error("Invalid checkpoint data")
79
+ data.todos ??= []
80
+ if (data.todos.length > 10000 || data.todos.some((todo) => !todo || Object.keys(todo).some((k) => !["id", "project", "task", "status", "createdAt", "updatedAt", "projectDirectoryName"].includes(k)) || !text(todo.id, 80) || !text(todo.project, 128) || !text(todo.task) || todo.task !== todo.task.trim() || !["pending", "completed", "archived"].includes(todo.status) || !text(todo.createdAt, 40) || !text(todo.updatedAt, 40) || (todo.projectDirectoryName !== undefined && !basenameValue(todo.projectDirectoryName)))) throw new Error("Invalid TODO data")
81
+ return data
82
+ }
83
+ async function read() {
84
+ try {
85
+ const raw = await readFile(storePath)
86
+ recordStoreRead(raw.length)
87
+ if (raw.length > maxBytes) throw new Error("Storage exceeds size limit")
88
+ return valid(JSON.parse(raw))
89
+ } catch (error) {
90
+ if (error.code === "ENOENT") { recordStoreRead(0); return empty() }
91
+ recordStoreError()
92
+ throw error
93
+ }
94
+ }
95
+ async function locked(fn) {
96
+ await mkdir(dirname(storePath), { recursive: true, mode: 0o700 })
97
+ const deadline = Date.now() + 5000
98
+ let handle, identity
99
+ while (!handle) {
100
+ try {
101
+ const candidate = await open(lockPath, "wx", 0o600)
102
+ let candidateIdentity
103
+ try {
104
+ candidateIdentity = await candidate.stat()
105
+ if (await hasRecoveryClaim()) {
106
+ await candidate.close()
107
+ await unlinkIfSame(lockPath, candidateIdentity)
108
+ } else {
109
+ handle = candidate
110
+ identity = candidateIdentity
111
+ }
112
+ } catch (error) {
113
+ await candidate.close().catch(() => {})
114
+ if (candidateIdentity) await unlinkIfSame(lockPath, candidateIdentity)
115
+ throw error
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (error.code !== "EEXIST") throw error
120
+ await recoverStaleLock()
121
+ }
122
+ if (!handle) {
123
+ if (Date.now() >= deadline) { recordStoreLockTimeout(); throw new Error("Storage is locked") }
124
+ recordStoreLockWait()
125
+ await new Promise((resolve) => setTimeout(resolve, 25))
126
+ }
127
+ }
128
+ try {
129
+ await handle.writeFile(String(process.pid))
130
+ return await fn()
131
+ } finally {
132
+ await handle.close()
133
+ await unlinkIfSame(lockPath, identity)
134
+ }
135
+ }
136
+ async function write(data) {
137
+ const started = Date.now()
138
+ try {
139
+ valid(data)
140
+ const serialized = JSON.stringify(data)
141
+ if (Buffer.byteLength(serialized) > maxBytes) throw new Error("Storage exceeds size limit")
142
+ const temp = `${storePath}.${process.pid}.${randomUUID()}.tmp`
143
+ const handle = await open(temp, "wx", 0o600)
144
+ try {
145
+ await handle.writeFile(serialized)
146
+ await handle.sync()
147
+ await handle.close()
148
+ await rename(temp, storePath)
149
+ } catch (error) {
150
+ await handle.close().catch(() => {})
151
+ await unlink(temp).catch(() => {})
152
+ throw error
153
+ }
154
+ recordStoreWrite(Buffer.byteLength(serialized), Date.now() - started)
155
+ } catch (error) {
156
+ recordStoreError()
157
+ throw error
158
+ }
159
+ }
160
+ const scope = (project, context) => {
161
+ if (!text(project, 128) || !text(context, 128)) throw new Error("project and context are required (max 128 chars)")
162
+ }
163
+ export async function getData() { return read() }
164
+ export async function saveHabitCounts(counts) {
165
+ return locked(async () => { const data = await read(); data.habits = { ...data.habits, version: 1, counts }; await write(data) })
166
+ }
167
+ export async function incrementHabitCounts(keys) {
168
+ return locked(async () => {
169
+ const data = await read()
170
+ const accepted = [...new Set(keys.filter((key) => categories.has(key)))]
171
+ if (!accepted.length) return
172
+ const today = new Date().toISOString().slice(0, 10)
173
+ const cutoff = new Date(`${today}T00:00:00.000Z`)
174
+ cutoff.setUTCDate(cutoff.getUTCDate() - 364)
175
+ for (const date of Object.keys(data.habits.daily)) if (date < cutoff.toISOString().slice(0, 10) || date > today) delete data.habits.daily[date]
176
+ const daily = data.habits.daily[today] ??= {}
177
+ for (const key of accepted) {
178
+ data.habits.counts[key] = Math.min(999, (data.habits.counts[key] ?? 0) + 1)
179
+ daily[key] = Math.min(Number.MAX_SAFE_INTEGER, (daily[key] ?? 0) + 1)
180
+ }
181
+ await write(data)
182
+ })
183
+ }
184
+ export async function clearHabits() {
185
+ return locked(async () => {
186
+ const data = await read()
187
+ data.habits = { version: 1, counts: {}, daily: {}, ...(data.habits.legacyImported === undefined ? {} : { legacyImported: data.habits.legacyImported }) }
188
+ await write(data)
189
+ })
190
+ }
191
+ export async function saveCheckpoint(project, context, summary, sessionTitle, projectDirectoryName) {
192
+ scope(project, context)
193
+ if (!text(summary)) throw new Error("summary is required (max 240 chars)")
194
+ return locked(async () => {
195
+ const data = await read()
196
+ const title = typeof sessionTitle === "string" ? sessionTitle.trim() : ""
197
+ const directory = basenameValue(typeof projectDirectoryName === "string" ? projectDirectoryName.trim() : projectDirectoryName)
198
+ const record = { id: randomUUID(), project, context, summary, createdAt: new Date().toISOString(), ...(title && title.length <= 200 ? { sessionTitle: title } : {}), ...(directory ? { projectDirectoryName: directory } : {}) }
199
+ data.checkpoints.push(record)
200
+ await write(data); return record
201
+ })
202
+ }
203
+ export async function listCheckpoints(project, context) {
204
+ scope(project, context)
205
+ return (await read()).checkpoints.filter((c) => c.project === project && c.context === context).sort((a,b) => b.createdAt.localeCompare(a.createdAt))
206
+ }
207
+ export async function listCheckpointContexts(project) {
208
+ if (!text(project, 128)) throw new Error("project is required (max 128 chars)")
209
+ return [...new Set((await read()).checkpoints.filter((c) => c.project === project).map((c) => c.context))].sort()
210
+ }
211
+ export async function deleteCheckpoint(project, context, id) {
212
+ scope(project, context)
213
+ if (!text(id, 80)) throw new Error("id is required (max 80 chars)")
214
+ return locked(async () => { const data = await read(), before = data.checkpoints.length; data.checkpoints = data.checkpoints.filter((c) => !(c.project === project && c.context === context && c.id === id)); if (before !== data.checkpoints.length) await write(data); return before !== data.checkpoints.length })
215
+ }
216
+ export async function updateCheckpoint(project, context, id, summary) {
217
+ scope(project, context)
218
+ if (!text(id, 80) || !text(summary)) throw new Error("id and summary are required (max 240 chars)")
219
+ return locked(async () => {
220
+ const data = await read(), checkpoint = data.checkpoints.find((c) => c.project === project && c.context === context && c.id === id)
221
+ if (!checkpoint) return false
222
+ checkpoint.summary = summary
223
+ await write(data)
224
+ return true
225
+ })
226
+ }
227
+ export async function clearCheckpoints(project, context) {
228
+ scope(project, context)
229
+ return locked(async () => { const data = await read(), before = data.checkpoints.length; data.checkpoints = data.checkpoints.filter((c) => c.project !== project || c.context !== context); if (before !== data.checkpoints.length) await write(data); return before - data.checkpoints.length })
230
+ }
231
+ export async function createTodo(project, taskValue, directoryNameValue) {
232
+ if (!text(project, 128)) throw new Error("project is required (max 128 chars)")
233
+ const task = typeof taskValue === "string" ? taskValue.trim() : ""
234
+ if (!text(task)) throw new Error("task is required (max 240 chars)")
235
+ const directoryName = basenameValue(typeof directoryNameValue === "string" ? directoryNameValue.trim() : directoryNameValue)
236
+ return locked(async () => {
237
+ const data = await read()
238
+ if (data.todos.length >= 10000) throw new Error("TODO limit reached")
239
+ const now = new Date().toISOString()
240
+ const todo = { id: randomUUID(), project, task, status: "pending", createdAt: now, updatedAt: now, ...(directoryName ? { projectDirectoryName: directoryName } : {}) }
241
+ data.todos.push(todo)
242
+ await write(data)
243
+ return todo
244
+ })
245
+ }
246
+ export async function listTodos(project, includeArchived = false) {
247
+ if (!text(project, 128) || typeof includeArchived !== "boolean") throw new Error("project and a boolean archive filter are required")
248
+ return (await read()).todos.filter((todo) => todo.project === project && (includeArchived || todo.status !== "archived"))
249
+ }
250
+ export async function updateTodo(project, id, taskValue, status) {
251
+ if (!text(project, 128) || !text(id, 80)) throw new Error("project and id are required")
252
+ const task = taskValue === undefined ? undefined : typeof taskValue === "string" ? taskValue.trim() : ""
253
+ if ((task !== undefined && !text(task)) || (status !== undefined && !["pending", "completed"].includes(status)) || (task === undefined && status === undefined)) throw new Error("a non-empty task or pending/completed status is required")
254
+ return locked(async () => {
255
+ const data = await read(), todo = data.todos.find((item) => item.project === project && item.id === id && item.status !== "archived")
256
+ if (!todo) return false
257
+ if (task !== undefined) todo.task = task
258
+ if (status !== undefined) todo.status = status
259
+ todo.updatedAt = new Date().toISOString()
260
+ await write(data)
261
+ return true
262
+ })
263
+ }
264
+ export async function archiveTodo(project, id) {
265
+ if (!text(project, 128) || !text(id, 80)) throw new Error("project and id are required")
266
+ return locked(async () => {
267
+ const data = await read(), todo = data.todos.find((item) => item.project === project && item.id === id && item.status !== "archived")
268
+ if (!todo) return false
269
+ todo.status = "archived"
270
+ todo.updatedAt = new Date().toISOString()
271
+ await write(data)
272
+ return true
273
+ })
274
+ }
275
+ export function getStoreStats() {
276
+ return storeStats()
277
+ }
278
+
279
+ export async function importLegacy(profile, checkpoint) {
280
+ return locked(async () => {
281
+ const data = await read()
282
+ let changed = false
283
+ if (!data.habits.legacyImported) {
284
+ const counts = profile?.version === 1 && profile.counts
285
+ ? Object.fromEntries(Object.entries(profile.counts).filter(([k,v]) => categories.has(k) && Number.isSafeInteger(v) && v > 0).map(([k,v]) => [k, Math.min(v,999)]))
286
+ : data.habits.counts
287
+ data.habits = { ...data.habits, version: 1, counts, legacyImported: true }
288
+ changed = true
289
+ }
290
+ if (checkpoint?.version === 1 && text(checkpoint.summary) && text(checkpoint.project, 128) && !data.checkpoints.some((c) => c.id === `legacy-${checkpoint.project}`)) {
291
+ data.checkpoints.push({ id: `legacy-${checkpoint.project}`, project: checkpoint.project, context: "legacy", summary: checkpoint.summary, createdAt: new Date(0).toISOString() })
292
+ changed = true
293
+ }
294
+ if (changed) await write(data)
295
+ })
296
+ }