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.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/assets/index-CMgH-UX9.js +21 -0
- package/dist/assets/index-CX37tAhW.css +1 -0
- package/dist/index.html +4 -0
- package/docs/mcp-setup.md +32 -0
- package/docs/metrics.md +200 -0
- package/index.js +501 -0
- package/package.json +51 -4
- package/src/App.vue +753 -0
- package/src/mcp.js +39 -0
- package/src/metrics.js +614 -0
- package/src/plugin-stats.js +215 -0
- package/src/store.js +296 -0
- package/src/ui-model.js +184 -0
- package/src/ui-navigation.js +13 -0
- package/src/ui-server.js +300 -0
- package/src/ui.css +221 -0
- package/src/ui.js +5 -0
|
@@ -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
|
+
}
|